보안 모델
Wippy는 속성 기반 접근 제어를 구현합니다. 모든 요청은 액터(누가)와 스코프(어떤 정책이 적용되는지)를 전달합니다. 정책은 액션, 리소스, 액터와 리소스의 메타데이터를 기반으로 접근을 평가합니다.
flowchart LR
A[Actor + Scope] --> PE[정책 평가] --> AD[허용/거부]
A -.->|아이덴티티
메타데이터| PE
PE -.->|조건
actor, resource, action| AD
엔트리 종류
| Kind | 설명 |
|---|---|
security.policy |
조건이 있는 선언적 정책 |
security.policy.expr |
표현식 기반 정책 |
security.token_store |
토큰 저장 및 검증 |
액터
액터는 액션을 수행하는 주체를 나타냅니다.
local security = require("security")
-- 메타데이터가 있는 액터 생성
local actor = security.new_actor("user:123", {
role = "admin",
team = "backend",
department = "engineering",
clearance = 3
})
-- 액터 속성 접근
local id = actor:id() -- "user:123"
local meta = actor:meta() -- {role="admin", ...}
컨텍스트의 액터
-- 컨텍스트에서 현재 액터 가져오기
local actor = security.actor()
if not actor then
return nil, errors.new("UNAUTHORIZED", "No actor in context")
end
정책
정책은 액션, 리소스, 조건, 효과로 접근 규칙을 정의합니다.
선언적 정책
# src/security/_index.yaml
version: "1.0"
namespace: app.security
entries:
# 관리자 전체 접근
- name: admin_policy
kind: security.policy
policy:
actions: "*"
resources: "*"
effect: allow
conditions:
- field: actor.meta.role
operator: eq
value: admin
groups:
- admin
# 읽기 전용 접근
- name: readonly_policy
kind: security.policy
policy:
actions:
- "*.read"
- "*.get"
- "*.list"
resources: "*"
effect: allow
groups:
- default
# 리소스 소유자 접근
- name: owner_policy
kind: security.policy
policy:
actions:
- read
- write
- delete
resources: "document:*"
effect: allow
conditions:
- field: meta.owner
operator: eq
value_from: actor.id
groups:
- default
# 클리어런스 없이 기밀 거부
- name: deny_confidential
kind: security.policy
policy:
actions: "*"
resources: "document:*"
effect: deny
conditions:
- field: meta.classification
operator: eq
value: confidential
- field: actor.meta.clearance
operator: lt
value: 3
groups:
- security
정책 구조
policy:
actions: "*" | "action" | ["action1", "action2"]
resources: "*" | "resource" | ["res1", "res2"]
effect: allow | deny
conditions: # 선택적
- field: "field.path"
operator: "eq"
value: "static_value"
# 또는
value_from: "other.field.path"
표현식 기반 정책
복잡한 로직의 경우 표현식 정책을 사용하세요:
- name: flexible_access
kind: security.policy.expr
policy:
actions:
- read
- write
resources: "file:*"
effect: allow
expression: |
(actor.meta.role == "editor" && action == "write") ||
(action == "read" && meta.public == true) ||
actor.id == meta.owner
groups:
- editors
조건
조건은 액터, 액션, 리소스, 메타데이터를 기반으로 동적 정책 평가를 허용합니다.
필드 경로
| 경로 | 설명 |
|---|---|
actor.id |
액터의 고유 식별자 |
actor.meta.* |
액터 메타데이터 (중첩 지원) |
action |
수행 중인 액션 |
resource |
리소스 식별자 |
meta.* |
리소스 메타데이터 |
연산자
| 연산자 | 설명 | 예제 |
|---|---|---|
eq |
같음 | actor.meta.role eq "admin" |
ne |
같지 않음 | meta.status ne "deleted" |
lt |
미만 | meta.priority lt 5 |
gt |
초과 | actor.meta.clearance gt 2 |
lte |
이하 | meta.size lte 1000 |
gte |
이상 | actor.meta.level gte 3 |
in |
배열에 값 포함 | action in ["read", "write"] |
nin |
배열에 값 미포함 | meta.status nin ["deleted", "archived"] |
exists |
필드 존재 | meta.owner exists true |
nexists |
필드 부재 | meta.deleted nexists true |
contains |
문자열 포함 | resource contains "sensitive" |
ncontains |
문자열 미포함 | resource ncontains "public" |
matches |
정규식 일치 | resource matches "^doc:.*" |
nmatches |
정규식 불일치 | actor.id nmatches "^system:.*" |
조건 예제
# 액터 역할 일치
conditions:
- field: actor.meta.role
operator: eq
value: admin
# 필드 비교
conditions:
- field: meta.owner
operator: eq
value_from: actor.id
# 숫자 비교
conditions:
- field: actor.meta.clearance
operator: gte
value: 3
# 배열 멤버십
conditions:
- field: actor.meta.role
operator: in
value:
- admin
- moderator
# 패턴 매칭
conditions:
- field: resource
operator: matches
value: "^api:/v[0-9]+/admin/.*"
# 다중 조건 (AND)
conditions:
- field: actor.meta.department
operator: eq
value: engineering
- field: meta.environment
operator: eq
value: production
스코프
스코프는 여러 정책을 보안 컨텍스트로 결합합니다.
local security = require("security")
-- 정책 가져오기
local admin_policy = security.policy("app.security:admin_policy")
local readonly_policy = security.policy("app.security:readonly_policy")
-- 정책으로 스코프 생성
local scope = security.new_scope()
scope = scope:with(admin_policy)
scope = scope:with(readonly_policy)
-- 스코프는 불변 - :with()는 새 스코프 반환
명명된 스코프 (정책 그룹)
그룹의 모든 정책 로드:
-- 그룹의 모든 정책으로 스코프 로드
local scope, err = security.named_scope("app.security:admin")
정책은 groups 필드를 통해 그룹에 할당됩니다:
- name: admin_policy
kind: security.policy
policy:
# ...
groups:
- admin # 이 정책은 "admin" 그룹에 있음
- default # 여러 그룹에 있을 수 있음
스코프 작업
-- 정책 추가
local new_scope = scope:with(policy)
-- 정책 제거
local new_scope = scope:without("app.security:temp_policy")
-- 정책이 스코프에 있는지 확인
local has = scope:contains("app.security:admin_policy")
-- 모든 정책 가져오기
local policies = scope:policies()
정책 평가
평가 흐름
1. 스코프의 각 정책 확인
2. 어떤 정책이라도 Deny 반환 → 결과는 Deny
3. 최소 하나의 Allow이고 Deny 없음 → 결과는 Allow
4. 해당 정책 없음 → 결과는 Undefined
평가 결과
| 결과 | 의미 |
|---|---|
allow |
접근 허용 |
deny |
접근 명시적 거부 |
undefined |
일치하는 정책 없음 |
-- 직접 평가
local result = scope:evaluate(actor, "read", "document:123", {
owner = "user:456",
classification = "internal"
})
if result == "deny" then
return nil, errors.new("FORBIDDEN", "Access denied")
elseif result == "undefined" then
-- 일치하는 정책 없음 - 엄격 모드에 따라 다름
end
빠른 권한 확인
-- 현재 컨텍스트의 액터와 스코프에 대해 확인
local allowed = security.can("read", "document:123", {
owner = "user:456"
})
if not allowed then
return nil, errors.new("FORBIDDEN", "Access denied")
end
토큰 스토어
토큰 스토어는 안전한 토큰 생성, 검증, 취소를 제공합니다.
설정
# src/auth/_index.yaml
version: "1.0"
namespace: app.auth
entries:
# 환경 변수 등록
- name: os_env
kind: env.storage.os
- name: AUTH_SECRET_KEY
kind: env.variable
variable: AUTH_SECRET_KEY
storage: app.auth:os_env
# 토큰용 백킹 스토어
- name: token_data
kind: store.memory
lifecycle:
auto_start: true
# 토큰 스토어
- name: tokens
kind: security.token_store
store: app.auth:token_data
token_length: 32
default_expiration: "24h"
token_key_env: "AUTH_SECRET_KEY"
토큰 스토어 옵션
| 옵션 | 기본값 | 설명 |
|---|---|---|
store |
필수 | 백킹 키-값 스토어 참조 |
token_length |
32 | 토큰 크기 (바이트, 256비트) |
default_expiration |
24h | 기본 토큰 TTL |
token_key |
없음 | HMAC-SHA256 서명 키 (직접 값) |
token_key_env |
없음 | 서명 키용 환경 변수 이름 |
프로덕션에서는 token_key_env를 사용하여 엔트리에 시크릿을 포함시키지 마세요. 환경 변수 등록은 환경 시스템을 참조하세요.
토큰 생성
local security = require("security")
-- 토큰 스토어 가져오기
local store, err = security.token_store("app.auth:tokens")
if err then
return nil, err
end
-- 액터와 스코프 생성
local actor = security.new_actor("user:123", {
role = "user",
email = "user@example.com"
})
local scope, _ = security.named_scope("app.security:default")
-- 토큰 생성
local token, err = store:create(actor, scope, {
expiration = "7d", -- 기본 만료 오버라이드
meta = {
device = "mobile",
ip = "192.168.1.1"
}
})
if err then
return nil, err
end
-- 토큰 형식: base64_token.hmac_signature (token_key가 설정된 경우)
-- 예: "dGVzdHRva2VuMTIz.a1b2c3d4e5f6"
토큰 검증
-- 토큰 검증
local actor, scope, err = store:validate(token)
if err then
return nil, errors.new("UNAUTHORIZED", "Invalid token")
end
-- 액터와 스코프가 저장된 데이터에서 재구성됨
print(actor:id()) -- "user:123"
토큰 취소
-- 단일 토큰 취소
local ok, err = store:revoke(token)
-- 완료 시 스토어 닫기
store:close()
컨텍스트 흐름
보안 컨텍스트는 함수 호출을 통해 전파됩니다.
컨텍스트 설정
local funcs = require("funcs")
-- 보안 컨텍스트로 함수 호출
local result, err = funcs.new()
:with_actor(actor)
:with_scope(scope)
:call("app.api:protected_endpoint", data)
컨텍스트 상속
| 컴포넌트 | 상속 |
|---|---|
| 액터 | 예 - 자식 호출로 전달 |
| 스코프 | 예 - 자식 호출로 전달 |
| 엄격 모드 | 아니오 - 애플리케이션 전체 |
함수는 호출자의 보안 컨텍스트를 상속합니다. 스폰된 프로세스는 새로 시작합니다.
서비스 레벨 보안
서비스에 대한 기본 보안 설정:
- name: worker_service
kind: process.lua
source: file://worker.lua
lifecycle:
auto_start: true
security:
actor:
id: "service:worker"
meta:
role: worker
service: true
policies:
- app.security:worker_policy
groups:
- workers
엄격 모드
보안 컨텍스트가 없을 때 접근을 거부하려면 엄격 모드를 활성화하세요:
# wippy.yaml
security:
strict_mode: true
| 모드 | 컨텍스트 없음 | 동작 |
|---|---|---|
| 일반 | 액터/스코프 없음 | 허용 (관대) |
| 엄격 | 액터/스코프 없음 | 거부 (보안 기본값) |
인증 흐름
HTTP 핸들러에서 토큰 검증:
local http = require("http")
local security = require("security")
local function protected_handler()
local req = http.request()
local res = http.response()
-- 토큰 추출 및 검증
local auth = req:header("Authorization")
if not auth then
return res:set_status(401):write_json({error = "Missing authorization"})
end
local token = auth:gsub("^Bearer%s+", "")
local store, _ = security.token_store("app.auth:tokens")
local actor, scope, err = store:validate(token)
if err then
return res:set_status(401):write_json({error = "Invalid token"})
end
-- 권한 확인
if not security.can("api.users.read", "users") then
return res:set_status(403):write_json({error = "Forbidden"})
end
res:write_json({user = actor:id()})
end
return { handler = protected_handler }
로그인 시 토큰 생성:
local actor = security.new_actor("user:" .. user.id, {role = user.role})
local scope, _ = security.named_scope("app.security:" .. user.role)
local store, _ = security.token_store("app.auth:tokens")
local token, err = store:create(actor, scope, {expiration = "24h"})
모범 사례
- 최소 권한 - 필요한 최소 권한만 부여
- 기본 거부 - 명시적 허용 정책 사용, 엄격 모드 활성화
- 정책 그룹 사용 - 역할/기능별로 정책 구성
- 토큰 서명 - 프로덕션에서 항상
token_key_env설정 - 짧은 만료 - 민감한 작업에 더 짧은 토큰 수명 사용
- 컨텍스트 조건 - 정적 정책보다 동적 조건 사용
- 민감한 액션 감사 - 보안 관련 작업 로깅
보안 모듈 참조
| 함수 | 설명 |
|---|---|
security.actor() |
컨텍스트에서 현재 액터 가져오기 |
security.scope() |
컨텍스트에서 현재 스코프 가져오기 |
security.can(action, resource, meta?) |
권한 확인 |
security.new_actor(id, meta?) |
새 액터 생성 |
security.new_scope(policies?) |
빈 또는 시드된 스코프 생성 |
security.policy(id) |
ID로 정책 가져오기 |
security.named_scope(group_id) |
모든 그룹 정책으로 스코프 가져오기 |
security.token_store(id) |
토큰 스토어 가져오기 |