테넌트, 사용자와 인증·인가
작업 공간은 WeKnora의 리소스 및 권한 경계이며, 지식 베이스, 모델, 에이전트, 세션과 스토리지 할당량은 모두 공간에 속합니다. 사용자는 여러 공간에 참여하고 공간마다 다른 역할을 가질 수 있습니다. 조직은 여러 공간을 연결하여 지식 베이스와 에이전트를 공유하는 데 사용됩니다. 백엔드에서는 작업 공간을 Tenant로 표현합니다.
멤버 초대, 리소스 공유와 API 연동 진입점은 다음과 같습니다. 전체 배포 환경을 관리하려면 별도의 플랫폼 권한이 필요합니다.
| 작업 | 진입점과 요구 사항 |
|---|---|
| 팀 멤버 초대 | 공간 설정 → 멤버 → 초대에서 상대에게 역할 부여(Owner / Admin / Contributor / Viewer) |
| 다른 팀에 지식 베이스 공유 | 조직 생성 → 두 공간을 모두 추가 → 지식 베이스에서 「조직에 공유」 |
| API 연동 | 공간 설정 → API Key에서 필요한 기능 선택(검색 / 질의응답 / 수집 / 관리), 필요하면 접근 가능한 지식 베이스 제한 |
| 전체 배포 환경 관리(전역 설정, 작업 큐, 공간 간 감사) | 공간 Owner와 별도로 부여되는 시스템 관리자 자격 필요. 플랫폼 관리와 시스템 관리자 참조 |
| 전체 공간 삭제 | 공간 설정에서 Owner가 실행(DELETE /tenants/:id). 해당 공간의 지식 베이스, Agent, 세션과 멤버 관계가 함께 삭제되며 되돌릴 수 없음 |
멤버 목록, 역할 드롭다운과 「멤버 초대」 버튼을 보여주세요. pending 초대를 하나 포함하면 좋아요.
website-docs/public/screenshots/settings-members.pngViewer는 열람과 질문, Contributor는 지식 베이스 생성과 문서 업로드, Admin은 멤버와 공간 설정 관리가 가능하며, Owner는 공간 삭제나 양도도 가능합니다. 리소스 수정에는 소유권 또는 공유 권한 제약도 적용됩니다. 역할 매트릭스는 참고 섹션을 확인하세요.
사용자는 비밀번호 또는 OIDC 싱글 사인온으로 로그인하고, 프로그램은 API Key로 접근합니다. 로그인한 사용자의 권한은 공간 역할과 리소스 소유 관계로 결정되며, API Key는 부여된 기능과 지식 베이스 범위에 따라 검증됩니다.
멤버 초대와 역할 부여
공간 설정의 「멤버」에서 사용자를 초대하고 업무 범위에 따라 역할을 부여합니다. 초대받은 사용자는 수락 후 현재 공간에 참여합니다. 공개 가입이 꺼져 있어도 유효한 초대로 가입할 수 있습니다. 기존 사용자도 두 번째 계정을 만들 필요 없이 초대를 수락하여 새 공간에 참여할 수 있습니다.
공간 멤버 역할과 조직 멤버 역할은 별도로 관리됩니다. 자료를 공유할 때는 지식 베이스의 공유 권한, 수신 공간의 조직 내 역할, 사용자 자신의 공간 역할을 함께 확인해야 합니다.
지식 베이스와 에이전트 공유
먼저 원본 공간과 수신 공간을 같은 조직에 참여시킨 다음, 권한이 있는 멤버가 지식 베이스나 에이전트를 해당 조직에 공유합니다. 수신자의 실제 권한은 공유 기록과 멤버 역할의 제약을 함께 받으며, 조직 공유가 사용자의 공간 역할을 자동으로 높이지는 않습니다.
프로그램용 API Key 설정
공간 설정에서 API Key를 생성하고 작업에 따라 검색, 질의응답, 수집 또는 관리 기능을 선택합니다. 자료 범위를 제한하려면 접근 가능한 지식 베이스도 지정합니다. 프로그램은 X-API-Key 요청 헤더로 자격 증명을 전달합니다. 구체적인 기능과 리소스 제한은 참고 섹션을 확인하세요.
공간 및 플랫폼 권한 관리
공간 삭제는 Owner가 실행하며, 해당 공간의 지식 베이스, 에이전트, 세션과 멤버 관계가 삭제됩니다. 전역 설정, 플랫폼 작업 큐와 공간 간 감사는 시스템 관리자가 관리합니다. 자세한 내용은 플랫폼 관리와 시스템 관리자를 참고하세요.
개념 개요
핵심 사항:
- 하나의 User는
tenant_members테이블을 통해 여러 Tenant에 동시에 속할 수 있으며, 각 멤버 관계는 독립된 역할을 가집니다. - 조직 멤버 관계는 테넌트 단위입니다(
OrganizationTenantMember는tenant_id단위). 공유 역시 "특정 테넌트가 특정 조직에 KB를 공유"하는 방식입니다. - API Key는 JWT 사용자와 완전히 독립된 머신 주체로, 테넌트 역할 계층을 재사용하지 않습니다.
인증과 인가 참고
가입과 로그인
가입 모드(invite-only)
internal/handler/auth.go + internal/config/config.go:
type AuthConfig struct {
RegistrationMode string // "self_serve"(기본값, 공개 가입) | "invite_only"(초대 전용)
DefaultTenantMode string // "create_personal"(기본값, 개인 테넌트 자동 생성) | "tenantless"(테넌트 없이 초대 대기)
}
func (c *AuthConfig) IsInviteOnly() bool {
return c != nil && c.RegistrationMode == AuthRegistrationModeInviteOnly
}판정은 두 단계로 나뉩니다. 이를 이해해야 「env를 바꿔도 적용되지 않는」 이유를 설명할 수 있습니다.
시작 시(applyAuthAndTenantDefaults())cfg.Auth.RegistrationMode를 구성합니다. DISABLE_REGISTRATION=true이면 값을 invite_only로 직접 덮어써서 YAML 값보다 우선합니다. env가 YAML보다 우선하는 이유는 「API의 가입 거부」와 「프런트엔드의 가입 진입점 숨김」(프런트엔드는 /auth/config를 읽음)을 일치시키기 위해서입니다. 그렇지 않으면 버튼은 보이지만 클릭 시 403이 발생할 수 있습니다.
매 요청 시(resolveRegistrationMode())두 출처만 비교합니다. 데이터베이스 system_settings의 auth.registration_mode 행 > 위에서 구성한 cfg 값 > 하드코딩된 최종 기본값 self_serve 순입니다. DISABLE_REGISTRATION은 요청마다 다시 읽지 않습니다.
따라서 시스템 관리자가 UI에서 auth.registration_mode를 self_serve로 설정하면, 배포 환경에 여전히 DISABLE_REGISTRATION=true가 있어도 공개 가입은 켜집니다. 완전히 끄려면 데이터베이스의 해당 행을 초기화해야 합니다(DELETE /system/admin/settings/auth.registration_mode).
invite_only 모드에서 POST /auth/register는 403을 반환하지만, 비밀번호 셀프 가입 경로만 차단합니다. 다음 두 경로에는 영향을 주지 않습니다.
- 초대 가입 엔드포인트
POST /auth/register-by-invite(설계상 의도된 동작. 초대 가입(register-by-invite) 참조); - OIDC 최초 로그인:
LoginWithOIDC()에서 이메일을 찾지 못하면provisionOIDCUser()로 계정을 바로 생성하며, 전체 과정에서 가입 모드를 읽지 않습니다. 즉 OIDC를 켜면invite_only로 IdP의 사용자를 차단할 수 없습니다. 범위를 제한하려면 IdP 측에서 설정하거나(애플리케이션 공개 범위 / 사용자 그룹), OIDC 자체를 꺼야 합니다.
비밀번호 가입 / 로그인
POST /auth/register:{username(2-50), email, password}.DefaultTenantMode에 따라 개인 테넌트 자동 생성 여부를 결정합니다(TenantProvisioningCreatePersonal/TenantProvisioningTenantless).POST /auth/login:{email, password}.LoginResponse{user, active_tenant, memberships[], token, refresh_token}를 반환하며,Preferences.LastActiveTenantID에 따라 활성 테넌트를 복원합니다.- 가입, 초대 가입, 비밀번호 변경과 관리자의 새 비밀번호 설정 모두 동일한 비밀번호 정책을 적용합니다. 기본값은 8–32자이며 최소한 문자와 숫자를 포함해야 합니다.
GET /auth/config는 현재 complex_password_enabled를 반환합니다. 활성화하면 대소문자와 특수 문자도 필요합니다. - 시스템 관리자는
auth.complex_password_enabled로 조정할 수 있으며, DB에 저장되지 않았으면WEKNORA_AUTH_COMPLEX_PASSWORD_ENABLED를 사용합니다. 정책 변경은 이후 생성하거나 변경하는 비밀번호에만 적용되며 기존 계정에 즉시 변경을 강제하지 않습니다. - 개인 프로필에서 직접 비밀번호를 바꾸려면 기존 비밀번호가 필요하며 새 비밀번호는 기존 값과 달라야 합니다. 성공하면 해당 사용자의 모든 세션을 철회하므로 다시 로그인해야 합니다. API 오류와 매개변수는 인증 API를 참고하세요.
초대 가입(register-by-invite)
internal/handler/auth_register_by_invite.go. 테넌트 Owner가 생성한 공유 초대 링크(share link, 공유 초대 링크(invite link) 참조)에는 token이 포함됩니다. 가입 페이지는 이 token으로 가입을 완료하며, 시스템이 invite_only 모드여도 가능합니다.
// POST /auth/register-by-invite
type registerByInviteRequest struct {
Token string `binding:"required"`
Email string `binding:"required,email"` // 가입자가 직접 입력하며 token과 연결되지 않음
Username string `binding:"required"`
Password string `binding:"required,min=6"`
}흐름: token 검증(LookupByToken)→ 이메일 미가입 여부 확인(이미 가입했으면 409 반환)→ tenantless 모드로 사용자 생성 → 초대한 테넌트를 사용자의 첫 테넌트로 설정 → AcceptByToken으로 tenant_members 행 생성(상태 active, 역할은 초대에 지정된 역할).
연계 엔드포인트 POST /auth/invitations/lookup(인증 불필요)은 가입 페이지에 표시할 초대 컨텍스트 {tenant_id, tenant_name, role, expires_at}를 반환합니다. token은 URL 접근 로그에 남지 않도록 POST 요청 본문으로 전달합니다. token이 유효하지 않거나 철회되었으면 410을 반환합니다.
기존 사용자의 초대 링크 참여
invite_only 배포 환경에서 초대 페이지는 사용자가 먼저 로그인한 뒤 POST /me/invitations/accept-by-token에 token을 제출하여 공간에 참여하도록 안내합니다. 이미 가입된 이메일로 계정을 다시 만들 필요는 없습니다. 기본 공간이 없는 사용자가 처음 참여하면 해당 공간이 기본 공간이 됩니다. register-by-invite는 여전히 유효한 초대로 새 계정을 생성하는 API입니다.
기존 사용자에 대한 이메일 초대에는 tenant.auto_accept_invitation도 적용됩니다. 기본값 false에서는 pending 초대를 생성하고 받은 편지함에서 확인하기를 기다립니다. true이면 바로 참여시키고 active 멤버를 반환하며 기존 pending 초대도 처리합니다. 프런트엔드는 GET /auth/me의 capabilities.auto_accept_invitation으로 이 스위치를 인식합니다. 임의의 공유 링크를 로그인 없는 진입점으로 바꾸는 기능은 아닙니다.
테넌트 멤버, 초대와 초대 링크
멤버 관리와 대상 지정 초대
Handler: internal/handler/tenant_member.go, tenant_invitation.go. /tenants/:id 그룹에는 공통으로 PathTenantMatch()가 적용됩니다(URL의 테넌트가 token의 활성 테넌트와 같아야 하며 슈퍼유저는 예외).
| 엔드포인트 | 최소 역할 | 설명 |
|---|---|---|
GET /tenants/:id/members | Viewer | active 멤버를 페이지 단위로 조회, q로 이메일/사용자 이름 부분 일치 필터링 |
POST /tenants/:id/members | Owner | 기존 사용자 직접 추가 {email, role} |
PUT /tenants/:id/members/:user_id | Owner | 역할 변경 |
DELETE /tenants/:id/members/:user_id | Owner | 멤버 제거 |
POST /tenants/:id/invitations | Owner | 기존 사용자 대상 지정 초대 {email, role, message} |
GET /tenants/:id/invitations | Viewer | 초대 목록 조회 |
DELETE /tenants/:id/invitations/:inv_id | Owner | 초대 철회 |
GET /me/invitations | 본인 | 초대 받은 편지함 |
POST /me/invitations/:inv_id/accept / .../decline | 본인 | 수락 / 거절 |
TenantInvitation 상태 머신: pending → accepted / declined / revoked / expired(만료는 지연 정리로 전환하고 rbac.invitation_expired 감사 이벤트 기록). 멤버와 초대의 전체 생명주기에 감사 이벤트가 있습니다: rbac.member_added / member_removed / member_role_changed / member_left / invitation_sent / invitation_accepted / invitation_declined / invitation_revoked(internal/types/audit_log.go).
공유 초대 링크(invite link)
internal/handler/tenant_invite_link.go. 대상 지정 초대와 같은 테이블에 저장합니다. InviteeUserID가 비어 있으면 공유 링크(여러 명 사용 가능, AcceptedCount로 집계), 비어 있지 않으면 대상 지정 초대입니다.
POST /tenants/:id/invite-links(Owner):{role, message}→invite_url반환({FrontendBaseURL}/register?token=....FrontendBaseURL은 YAMLfrontend_base_url→ 환경 변수FRONTEND_BASE_URL→ 최종 기본값인 상대 경로 순으로 결정);GET /tenants/:id/invite-links(Viewer)로 조회하고,DELETE /tenants/:id/invite-links/:inv_id(Owner)로 철회합니다.
링크는 만료되거나 철회될 때까지 유효합니다. 초대 가입(register-by-invite)의 register-by-invite와 함께 invite-only 모드의 계정 생성 과정을 완성합니다.
조직과 공유 공간
조직 생명주기
internal/application/service/organization.go:
- 조직 생성 시 고유한
InviteCode를 생성합니다. 유효 기간은invite_code_validity_days ∈ {0(영구), 1, 7, 30}이며 기본값은 7일입니다(ValidInviteCodeValidityDays허용 목록, 잘못된 값은ErrInvalidValidityDays오류); GetOrganizationByInviteCode로 초대 코드를 사용해 참여합니다(ErrInviteCodeNotFound/ErrInviteCodeExpired구분).RequireApproval=true이면 승인 대기 중인 join request를 생성합니다;Searchable=true인 조직은SearchSearchableOrganizations로 찾을 수 있습니다;- 초대 코드와 승인 대기 건수는 "조직 admin 또는 owner 테넌트"만 볼 수 있습니다(
internal/handler/organization.go의isAdmin || isOwner판정).
초대 검색: 사용자가 아닌 공간(테넌트) 단위
조직 초대는 작업 공간을 대상으로 합니다. GET /organizations/:id/search-tenants는 공간 이름으로 검색하며 조직 admin만 호출할 수 있습니다. 사용자는 여러 공간에 속할 수 있으므로 검색 결과는 공간 후보를 반환합니다.
// SearchTenantsForInvite:
// 1. 호출자 테넌트가 조직 admin인지 검증
// 2. 이미 조직에 속한 테넌트 제외 (existingTenantIDs)
// 3. tenantService.SearchTenants로 이름 검색(pageSize = limit*2, limit 상한 50)
// 4. 삽입 순서대로 중복 제거, 이름을 확인할 수 없는 defunct 테넌트 제외, limit까지 제한기존 엔드포인트 GET /organizations/:id/search-users는 호환 shim으로 유지하며 SearchTenantsForInvite에 직접 위임합니다(응답은 이미 새로운 tenant-candidate 형태이며 @Deprecated로 표시).
POST /organizations/:id/invite(조직 admin 전용)는 멤버를 직접 추가합니다. tenant_id 경로를 우선 사용합니다(선택 항목 representative_user_id의 대표 사용자가 대상 테넌트 소속이 아니면 경고 후 해당 필드를 버리며, 요청 자체를 실패시키지는 않음). 기존 SDK의 user_id 경로도 호환합니다(해당 사용자의 테넌트 역조회).
KB 공유 모델과 권한 계산
internal/types/organization.go + internal/application/service/kbshare.go:
type KnowledgeBaseShare struct {
ID string
KnowledgeBaseID string
OrganizationID string
SharedByUserID string
SourceTenantID uint64 // 공유 원본 테넌트
Permission OrgMemberRole // 공유로 부여하는 최대 권한(viewer/editor/admin)
}
// AgentShare는 같은 구조이며 Agent를 대상으로 함.공유 전제 조건(ShareKnowledgeBase): 호출자 테넌트가 해당 KB를 소유해야 하며(kb.TenantID == tenantID), 대상 조직에서 editor+ 역할이어야 합니다. 중복 공유는 권한 업데이트로 처리합니다.
공유 관리를 허용하는 세 가지 예외 경로(callerCanManageShare, 권한 변경 / 공유 철회에 사용):
- 호출자가 원래 공유한 사람인 경우(같은 user id);
- 호출자 테넌트가 원본 테넌트이고 호출자가 테넌트 Admin+인 경우(소유권은 테넌트 단위이므로 원래 공유한 사람이 떠나도 테넌트 Admin이 계속 관리 가능);
- 호출자 테넌트가 대상 조직의 admin인 경우(원래 공유한 사람이 떠난 후 org admin이 공유를 복구 가능).
유효 권한 = 여러 계층의 교집합(최솟값 적용):
// 최종 권한 = Min(공유 기록의 Permission, 호출자 테넌트의 조직 내 OrgMemberRole)
// 여기에 테넌트 역할 상한을 추가 적용:
func applyTenantRoleCap(p types.OrgMemberRole, callerTenantRole types.TenantRole) types.OrgMemberRole {
// 테넌트에서 Viewer인 사용자는 공유 측에서 editor+를 부여해도 viewer로 제한
if callerTenantRole == types.TenantRoleViewer && p.HasPermission(types.OrgRoleEditor) {
return types.OrgRoleViewer
}
return p
}공유 관련 작업은 KB 활동 스트림에 기록됩니다: kb.share_added / kb.share_permission_changed / kb.share_removed.
RBAC: 역할, 소유권과 가드 매트릭스
인가는 서로 독립적인 세 가지 메커니즘으로 구성되며, 모두 internal/router/rbac.go의 rbacGuards로 모입니다.
- 역할 가드(role-only):
Viewer()/Contributor()/Admin()/Owner()/SystemAdmin(). "호출자의 현재 테넌트 역할은 무엇인가"를 확인합니다. - 소유권 가드(ownership-or-role):
OwnedKBOrAdmin()등. "호출자가 이 리소스의 생성자이거나 최소 Admin+인가"를 확인합니다. - KB 접근 가드(KB-access):
KBAccessRead()/KBAccessWrite(). "호출자의 테넌트가 이 KB에 접근할 수 있는가"를 확인합니다(자체 소유 / 조직 공유 / 공유 Agent를 통해 열람 가능).
역할별 기능 매트릭스
| 기능 | Owner (40) | Admin (30) | Contributor (20) | Viewer (10) |
|---|---|---|---|---|
| 테넌트 삭제 / 소유권 이전 / API Key 관리 | ✓ | ✗ | ✗ | ✗ |
| 멤버 추가 / 제거, 역할 변경, 초대 발송 | ✓ | ✗(handler에서 Owner로 제한) | ✗ | ✗ |
| 테넌트 인프라 설정(모델 / 벡터 저장소 / IM / MCP / 웹 검색 / 스토리지 백엔드 / 데이터 소스) | ✓ | ✓ | ✗ | ✗ |
지식 베이스 콘텐츠 비우기(DELETE /knowledge-bases/:id/knowledge) | ✓ | ✓ | ✗ | ✗ |
| 다른 사람이 만든 KB / Agent / 지식 / chunk / Wiki / 태그 수정·삭제 | ✓ | ✓ | ✗ | ✗ |
| KB / Agent 생성, 자신에게 Agent 복사 | ✓ | ✓ | ✓ | ✗ |
| 자신이 만든 KB와 하위 리소스 수정·삭제 | ✓ | ✓ | ✓ | ✗ |
자신의 세션 생성·관리, 질의응답 시작(/sessions, /knowledge-chat, /agent-chat 모두 Viewer+) | ✓ | ✓ | ✓ | ✓ |
| 멤버 목록 / 초대 목록 / KB 목록 / 지식 / 검색 / 미리 보기 조회 | ✓ | ✓ | ✓ | ✓ |
internal/router/rbac.go 상단의 설계 주석은 제품 의미를 다음과 같이 요약합니다.
- Owner / Admin: 테넌트 내 모든 항목 관리;
- Contributor: 자신이 만든 리소스 관리, 다른 사람의 리소스는 읽기 전용;
- Viewer: 모두 읽기 전용;
- 새 리소스 생성은 최소 Contributor, 테넌트 인프라 설정은 Admin+ 필요.
놓치기 쉬운 예외가 두 가지 있습니다. 멤버 추가·제거·역할 변경과 초대 발송은 Owner 전용이며 Admin도 할 수 없습니다(routes_auth_tenant.go에는 g.Owner()가 적용되며 멤버 목록만 Viewer+). Viewer가 「아무것도 만들 수 없는」 것은 아닙니다. 세션은 자신의 작업 데이터이므로 Viewer도 세션을 만들고 질문할 수 있지만, 지식 베이스와 Agent는 만들 수 없습니다.
가드 선택 규칙(Q1 / Q2)
rbac.go에는 새 라우트의 가드를 선택하는 방법이 명시되어 있습니다.
- Q1: 리소스에 creator가 있는가? 있으면(KB, Agent, 지식 문서, Chunk, WikiPage, FAQ 항목, KB 태그)→ 변경 라우트에
OwnedXxxOrAdmin사용. 없으면(Model, VectorStore, IM 채널, WebSearchProvider, DataSource, MCPService 등 테넌트 단위 인프라)→Admin()사용. 생성 진입점(리소스가 아직 없음)→Contributor()사용. - Q2: 부수 효과가 개인적인가, 공개적인가? 개인적이면(예:
POST /agents/:id/copy는 자신에게만 복사)→Contributor()로 충분합니다. 공개적이면(KB를 조직에 공유, 테넌트 전체 Agent 비활성화, 소유권 이전)→OwnedXxxOrAdmin또는Admin을 사용합니다.
소유권 가드 목록
| 가드 | 해석 경로 | 적용 라우트 |
|---|---|---|
OwnedKBOrAdmin | :id → KB.CreatorID | KB 업데이트 / 삭제 / pin / 지식 업로드 / 태그 CRUD |
OwnedKBOrAdminFromKbIDParam | :kbId → KB.CreatorID | /initialization/* KB 설정 라우트 |
OwnedAgentOrAdmin | :id → Agent.CreatorID(내장 Agent의 creator는 비어 있으며 Admin+만 변경 가능) | Agent 변경 |
OwnedKnowledgeKBOrAdmin | knowledge :id → 소속 KB.CreatorID | 지식 업데이트 / 삭제 / 재파싱 / 이미지 편집 |
OwnedChunkKBOrAdmin / ...FromChunkID | :knowledge_id 또는 chunk :id → KB.CreatorID | chunk 변경 |
OwnedWikiKBOrAdmin | :kb_id → KB.CreatorID | Wiki 페이지 CRUD |
하위 리소스는 부모 KB의 접근 제어를 상속해야 합니다(주석에서는 FAQ/Tag, agent share, KB share에 잘못된 기준을 연결했던 버그를 수정한 사실을 명시함).
미들웨어 의미(internal/middleware/rbac.go)
RequireRole / RequireOwnershipOrRole의 판정 순서:
- API Key 주체는 즉시 통과(인가는 라우트 선언 메커니즘의 APIKeyGate에서 처리하며, 합성 시스템 사용자가
creator_id와 일치할 수 없음); - 역할 충족 → 통과;
- 테넌트 간 슈퍼유저(
IsCrossTenantSuperuser)→ 통과; - RBAC 강제 적용이 꺼짐(
tenant.enable_rbac=false, 점진적 적용 모드)→ 로그만 기록하고 통과; - ownership 가드에서 creator 조회: 리소스 없음 → handler가 404를 반환하도록 통과; 조회 실패 → 503; creator == 현재 사용자 → 통과;
- 그 외에는 403 + 감사 로그(
AuditActionAccessDenied = "rbac.access_denied").
강제 적용 스위치 TenantConfig.EnableRBAC: nil 또는 true = 강제 적용(현재 기본값), false = 로그만 기록하고 거부하지 않음(릴리스 전환용). 환경 변수 WEKNORA_TENANT_ENABLE_RBAC로 덮어쓸 수 있습니다.
RequireSystemAdmin: JWT 사용자는 IsSystemAdmin=true여야 하고, API Key는 platform key여야 합니다(tenant key는 모두 403).
KB 접근 가드(테넌트 간 공유 경로)
middleware/kb_access.go(rbac.go의 KBAccess* 계열로 래핑)는 세 가지 접근 경로를 통합합니다.
1. 자체 소유 KB → Admin 수준의 전체 접근과 동일
2. 조직 공유 KB (Plan 3) → 공유 권한으로 상한 제한
3. 공유 Agent를 통해 열람 가능 → 읽기 전용(KBAccessRead 계층에서만 활성화)가드가 성공하면 (KB, 유효 테넌트 ID, 권한)을 context에 저장하고 요청의 테넌트 ID를 유효 테넌트로 변경합니다. 하위 handler는 KB가 자체 소유인지 공유인지 알 필요가 없습니다. 변형인 KBAccessReadFromKnowledgeIDParam / ...FromChunkIDParam은 knowledge / chunk ID에서 KB를 역조회할 수 있습니다. 읽기 라우트는 최소 OrgRoleViewer, 쓰기 라우트는 최소 OrgRoleEditor가 필요합니다.
API Key 체계
기능(Capabilities) 목록
internal/types/tenant_api_key.go. API Key는 테넌트 역할을 재사용하지 않습니다. key는 FullAccess이거나 명시적인 기능 집합을 가집니다. 정책이 선언되지 않은 라우트는 API Key 접근을 기본적으로 거부합니다(default-deny).
| 기능 | 설명 |
|---|---|
retrieve | 지식 베이스 데이터 읽기 / 검색(KB 목록, 지식 상세, hybrid-search 등) |
chat | 세션 흐름: session 생성, knowledge-chat / agent-chat, 메시지 로드와 삭제 |
read_agents | Agent 목록 및 상세 조회(생성·수정 제외) |
ingest | 콘텐츠 쓰기: 문서 업로드, chunk / FAQ / 태그 / Wiki 편집, 지식 일괄 삭제와 이동 |
manage_kbs | KB 생명주기: 생성 / 복사 / 복제본 / 업데이트 / 삭제 / 초기화 설정 |
manage_agents | Agent 추가·삭제·수정과 복사 |
message_history | 테넌트 단위 채팅 기록 검색·조회(POST /messages/search 등, chat과 독립적) |
manage_models | 모델 정의와 자격 증명 관리 |
manage_mcp_services | MCP 서비스와 자격 증명 관리 |
manage_datasources | 데이터 소스 커넥터와 동기화 작업 관리 |
manage_channels | Embed / IM 채널 연동 관리 |
manage_vector_stores | 벡터 저장소와 파서 관리 |
manage_storage_backends | 객체 스토리지 백엔드 관리 |
manage_web_search | 웹 검색 설정 관리 |
run_evaluations | 평가 작업 실행·조회 |
manage_members | 테넌트 멤버와 초대 관리 |
manage_spaces | 조직 / 공유 공간 멤버 관계 관리 |
manage_tenant_settings | 테넌트 통합 설정 읽기·쓰기 |
system_tenants_read / system_tenants_manage | 플랫폼 수준: 테넌트 관리(platform key 전용) |
system_settings_read / system_settings_manage | 플랫폼 수준: 시스템 설정 |
system_runtime_read / system_runtime_manage | 플랫폼 수준: 런타임 큐 / 작업 |
system_audit_read | 플랫폼 수준: 감사 로그 |
라우트 선언 메커니즘
internal/router/rbac.go에서 API Key로 접근 가능한 모든 라우트는 apiKeyGroup / apiKeyRoute를 통해 APIKeyRoutePolicy를 명시적으로 등록합니다(middleware.APIKeyRouteAuthorizer가 유일한 기준 정보).
// 정책 생성자
apiKeyAny() // 유효한 모든 key
apiKeyFullAccess() // FullAccess key 전용
apiKeyPlatform(caps...) // platform key + 지정 기능 전용
apiKeyRetrieve(base) / apiKeyChat(base) / apiKeyIngest(base) / ...시작 시 assertAPIKeyPoliciesMatchRoutes가 선언된 모든 정책이 실제 등록된 라우트에 대응하는지 검증하며, 설정이 어긋나면 즉시 panic이 발생합니다. router_api_key_capabilities_test.go에서 확인할 수 있는 대표 매핑:
| 라우트 | 필요한 기능 |
|---|---|
POST /sessions, POST /knowledge-chat/:session_id, POST /agent-chat/:session_id, GET /messages/:session_id/load | chat |
GET /agents, GET /agents/:id, GET /agents/:id/suggested-questions | read_agents |
POST/PUT/DELETE /agents, POST /agents/:id/copy | manage_agents |
PUT/DELETE /knowledge-bases/:id, POST /initialization/initialize/:kbId | manage_kbs |
POST /messages/search, GET /messages/chat-history-stats | message_history(chat이 아님) |
GET /system/admin/settings | platform key + system_settings_read |
POST /system/admin/runtime/queues/:queue/tasks/:task_id/actions/:action | platform key + system_runtime_manage |
KB Allow-list
KnowledgeBaseIDs가 비어 있지 않으면 key는 목록 내 KB에만 접근할 수 있습니다(knowledge_api_key_scope_test.go에서 확인).
// 단일 KB가 범위를 벗어나면 → 403
requireTenantAPIKeyKnowledgeBase(ctx, "kb-2") // scope에 kb-1만 포함 → forbidden
// 일괄 작업 중 하나의 KB라도 범위를 벗어나면 → 전체 403(일부만 겹치는 요청 거부)
requireTenantAPIKeyKnowledgeBases(ctx, "kb-1", "kb-2") // → forbidden추가적인 엄격한 제한: platform key로 다른 platform key를 생성할 수 없습니다. API Key 주체는 ownership 판정에 참여하지 않습니다(RBAC: 역할, 소유권과 가드 매트릭스 참조).
OIDC 싱글 사인온
설정
internal/config/config.go의 OIDCAuthConfig:
| 설정 항목 | 설명 |
|---|---|
enable | OIDC 활성화 여부 |
issuer_url | 예상 Issuer, id_token 검증에 사용 |
jwks_uri | 서명 공개 키 집합 주소. 환경 변수 OIDC_AUTH_JWKS_URI |
discovery_url | OpenID Connect Discovery 주소(.well-known/openid-configuration) |
provider_display_name | 로그인 버튼에 표시할 이름 |
client_id / client_secret | 클라이언트 자격 증명(secret은 json:"-"로 직렬화하여 프런트엔드에 전달하지 않음) |
authorization_endpoint / token_endpoint / user_info_endpoint | 엔드포인트 수동 지정 |
scopes | 요청할 scope(예: openid email profile) |
user_info_mapping.username / .email | claims 필드 매핑(기본값 name / email) |
인가/Token 엔드포인트는 명시적으로 설정할 수 있습니다. 두 항목을 모두 입력했더라도 issuer 또는 jwks_uri가 불완전하면 discovery로 검증 정보를 보완해야 합니다. 신뢰할 수 있는 검증 설정이 없을 때 id_token의 페이로드만 파싱해서 로그인할 수는 없습니다.
라우트(internal/router/router.go):
r.GET("/auth/oidc/config", handler.GetOIDCConfig) // 프런트엔드에서 활성화 여부 확인
r.GET("/auth/oidc/url", handler.GetOIDCAuthorizationURL) // 인가 URL 조회
r.GET("/auth/oidc/start", handler.OIDCStart) // 302로 바로 로그인 시작
r.GET("/auth/oidc/callback", handler.OIDCRedirectCallback) // 인가 코드 콜백기업 포털은 /api/v1/auth/oidc/start로 직접 연결할 수 있습니다. 백엔드는 302를 반환하여 IdP로 이동시키고 요청 origin에 따라 콜백 주소를 구성합니다. 리버스 프록시 뒤에 배포할 때는 외부 scheme/host를 올바르게 전달하고 IdP에 해당 콜백 주소를 등록해야 합니다. 이 API는 임의의 로그인 후 이동 대상을 허용하지 않습니다.
흐름과 보안 설계
internal/application/service/user.go:
GetOIDCAuthorizationURL: 24바이트 랜덤nonce를 생성하고secutils.SignOIDCState로{nonce, redirect_uri}를 서명하여 state에 포함합니다(CSRF / 재전송 / 콜백 주소 변조 방지). nonce는 HttpOnly cookie로 전달합니다(응답 JSON에서는json:"-"로 생략).LoginWithOIDC: 인가 코드를 token으로 교환 → id_token을 사용하면 먼저 JWKS로 서명, issuer, audience와 유효 기간 검증 → UserInfo 엔드포인트의 사용자 정보 병합(user_info_mapping에 따라 매핑)→ email로 로컬 사용자 매칭. 없으면provisionOIDCUser로 계정 자동 생성 → 비밀번호 로그인과 완전히 동일한 로컬 JWT 쌍 발급.
access_token만 있으면 UserInfo에서 신원을 가져올 수 있습니다. JWKS가 없으면 서명을 검증하지 않은 id_token claims를 사용할 수 없지만, access_token과 UserInfo 엔드포인트가 있으면 UserInfo 경로를 사용할 수 있습니다. 서명이 검증된 id_token은 UserInfo 요청 실패 시 대체 수단으로 사용할 수 있습니다.
계정 자동 생성 세부 사항:
- 테넌트 모드는
auth.default_tenant_mode에서 가져옵니다(create_personal은 개인 테넌트 자동 생성 /tenantless는 초대 대기); - 사용자 이름 후보: OIDC username → email 접두부 →
oidc-user. 충돌하면-1..-20숫자 접미사를 추가하고, 그래도 충돌하면 Unix 타임스탬프 사용; - 32자 랜덤 비밀번호를 생성하여 저장합니다(사용자는 알 수 없으며 OIDC 로그인만 가능);
- SPA의 첫 로그인 안내를 위해 응답에
is_new_user를 포함합니다.IsActive=false인 계정은 로그인을 거부합니다.
설정 빠른 참고
| 설정 항목 | 값 | 기본값 | 용도 |
|---|---|---|---|
auth.registration_mode | self_serve / invite_only | self_serve | 공개 가입 스위치(DB system_settings에서 실시간 변경 가능) |
auth.default_tenant_mode | create_personal / tenantless | create_personal | 신규 사용자의 개인 테넌트 자동 생성 여부 |
tenant.enable_rbac | true / false | true | RBAC 강제 적용 / 로그 전용 모드 |
JWT_SECRET(환경 변수) | 임의 문자열 | 랜덤 32바이트 | JWT HMAC 키 |
SYSTEM_AES_KEY(환경 변수) | AES 키 | 미설정 | API Key 평문 DB 저장 시 암호화 |
oidc.* | 설정 참조 | 꺼짐 | OIDC 싱글 사인온 |
frontend_base_url / FRONTEND_BASE_URL | URL | 상대 경로 | 초대 링크의 가입 페이지 주소 |
Tenant.StorageQuota | 바이트 | 10737418240(10GB) | 테넌트 스토리지 할당량 |
JWT 메커니즘
internal/application/service/user.go에 구현되어 있으며 github.com/golang-jwt/jwt(HMAC-SHA256)를 사용합니다.
키 출처
func getJwtSecret() string {
// 1) 환경 변수 JWT_SECRET
// 2) 없으면 시작 시 32바이트 보안 랜덤 키 생성(Base64), 프로세스 재시작 후 기존 token 무효화
}발급(Access + Refresh 이중 token)
accessClaims := jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"tenant_id": activeTenantID, // 요청의 테넌트 범위가 token에 고정됨
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"type": "access",
}
refreshClaims := jwt.MapClaims{
"user_id": user.ID,
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
"type": "refresh",
}| Token | 유효 기간 | Claims 핵심 |
|---|---|---|
| Access Token | 24시간 | user_id / email / tenant_id / type=access |
| Refresh Token | 7일 | user_id / type=refresh(tenant_id 없음) |
두 token 모두 서버 측 철회를 위해 auth_tokens 테이블에 기록합니다.
검증과 갱신
ValidateToken의 검사 흐름:
- 서명 알고리즘은 HMAC 계열이어야 합니다(알고리즘 혼동 공격 방지);
type=refresh인 token은 access token으로 사용할 수 없습니다(isRefreshTokenClaims);auth_tokens테이블에서IsRevoked를 확인합니다(로그아웃 = 기록 철회);- claims에서
user_id를 추출해 사용자를 로드하고tenant_id를 활성 테넌트로 사용합니다.
테넌트 전환 시 token을 재발급합니다. SwitchTenant는 대상 테넌트의 active 멤버 자격을 검증한 후(테넌트 간 슈퍼유저 제외), 먼저 대상 공간을 「최근 활성 테넌트」 환경설정(Preferences.LastActiveTenantID)에 기록합니다. 이어 새 tenant_id claim을 포함한 token 쌍을 발급하고 기존 refresh token 철회를 최선의 노력으로 시도합니다. refresh JWT에는 tenant_id가 없으므로 다음 로그인과 refresh 모두 이 환경설정에 따라 진입 공간을 결정합니다. 환경설정 저장에 실패하면 재발급 전체가 실패합니다. home으로 돌아갈 때는 home ID를 기록합니다(현재 진입 공간의 의미상 SPA가 0을 보내 환경설정을 지우는 것과 동일). 이 환경설정은 계정 단위이므로 한 번 재발급하면 모든 기기의 다음 진입 공간이 바뀝니다.
데이터 모델
Tenant(테넌트 / 작업 공간)
internal/types/tenant.go:
type Tenant struct {
ID uint64 `json:"id" gorm:"primaryKey"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status" gorm:"default:'active'"`
RetrieverEngines RetrieverEngines `json:"retriever_engines" gorm:"type:json"`
Business string `json:"business"`
StorageQuota int64 `json:"storage_quota" gorm:"default:10737418240"` // 기본값 10GB
StorageUsed int64 `json:"storage_used" gorm:"default:0"`
ContextConfig *ContextConfig `json:"context_config" gorm:"type:jsonb"`
WebSearchConfig *WebSearchConfig `json:"web_search_config" gorm:"type:jsonb"`
ParserEngineConfig *ParserEngineConfig `json:"parser_engine_config" gorm:"type:jsonb"`
Credentials *CredentialsConfig `json:"credentials" gorm:"type:jsonb"`
StorageEngineConfig *StorageEngineConfig `json:"storage_engine_config" gorm:"type:jsonb"`
DefaultStorageBackendID *string `json:"default_storage_backend_id,omitempty"`
ChatHistoryConfig *ChatHistoryConfig `json:"chat_history_config" gorm:"type:jsonb"`
RetrievalConfig *RetrievalConfig `json:"retrieval_config" gorm:"type:jsonb"`
APIPrincipalConfig *APIPrincipalConfig `json:"-" gorm:"type:jsonb"`
// CreatedAt / UpdatedAt / DeletedAt(소프트 삭제)
}테넌트는 할당량(StorageQuota / StorageUsed, 기본값 10GB)과 각종 테넌트 단위 설정(검색 엔진, 웹 검색, 파싱 엔진, 자격 증명, 스토리지 엔진, 채팅 기록 등)을 담는 지점입니다.
User(사용자)
internal/types/user.go:
type User struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"uniqueIndex;not null"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
PasswordHash string `json:"-" gorm:"not null"`
Avatar string `json:"avatar"`
TenantID uint64 `json:"tenant_id" gorm:"index"` // 선호/기본 테넌트
IsActive bool `json:"is_active" gorm:"default:true"`
CanAccessAllTenants bool `json:"can_access_all_tenants" gorm:"default:false"` // 테넌트 간 슈퍼유저
IsSystemAdmin bool `json:"is_system_admin" gorm:"default:false;index"` // 플랫폼 관리자
Preferences UserPreferences `json:"preferences" gorm:"type:jsonb"`
}
type UserPreferences struct {
// 마지막 활성 테넌트 ID, 로그인 시 컨텍스트 복원에 사용
LastActiveTenantID *uint64 `json:"last_active_tenant_id,omitempty"`
}두 가지 특수 플래그:
CanAccessAllTenants: 공간 간 슈퍼유저입니다. 두 스위치가 모두 true여야 적용됩니다. 사용자 행의CanAccessAllTenants와 배포 수준의tenant.enable_cross_tenant_access/WEKNORA_TENANT_ENABLE_CROSS_TENANT_ACCESS입니다(middleware/access.go의IsCrossTenantSuperuser()는 설정을 먼저 확인한 뒤 사용자를 확인하며, 설정이 꺼져 있으면 로그인 응답에서도 이 필드를 false로 변경). 적용되면 공간 역할 검사를 우회하여/tenants/all,/tenants/search등 공간 간 엔드포인트에 접근할 수 있습니다.POST /tenants(새 공간 생성)는 공간 간 엔드포인트가 아니며, 로그인한 모든 사용자가 호출할 수 있습니다(셀프 생성 정책과 할당량 제한 적용).IsSystemAdmin: 플랫폼 수준 관리자(system admin)로, 모든 테넌트 역할과 독립적이며/system/admin/*제어 영역에 사용합니다. 특정 공간이 아니라 전체 배포 환경을 관리합니다. 최초 관리자 생성 방법과 가능한 작업은 플랫폼 관리와 시스템 관리자를 참고하세요.
TenantMember와 테넌트 역할
internal/types/tenant_member.go:
type TenantRole string
const (
TenantRoleOwner TenantRole = "owner" // 전체 제어: 테넌트 삭제, 소유권 이전, API Key와 멤버 관리
TenantRoleAdmin TenantRole = "admin" // 멤버, 모델, 벡터 저장소, MCP, IM 등 테넌트 인프라 관리
TenantRoleContributor TenantRole = "contributor" // KB / Agent 생성, 자신이 만든 리소스 편집
TenantRoleViewer TenantRole = "viewer" // 읽기 전용
)
var tenantRoleLevel = map[TenantRole]int{
TenantRoleOwner: 40, TenantRoleAdmin: 30,
TenantRoleContributor: 20, TenantRoleViewer: 10,
}
func (r TenantRole) HasPermission(required TenantRole) bool {
return r.Level() >= required.Level()
}type TenantMember struct {
ID uint64
UserID string
TenantID uint64
Role TenantRole // 기본값 contributor
Status TenantMemberStatus // active / invited / suspended
InvitedBy *string
JoinedAt time.Time
}로그인 응답은 Membership{TenantID, TenantName, Role} 프로젝션 목록을 반환하며, 프런트엔드는 이를 바탕으로 작업 공간 전환기를 렌더링합니다.
TenantAPIKey(API Key)
internal/types/tenant_api_key.go:
type TenantAPIKey struct {
ID uint64
TenantID *uint64 // platform key는 NULL
ScopeType APIKeyScopeType // "tenant" | "platform"
Name string
KeyHash string `json:"-" gorm:"uniqueIndex"` // 테이블 조회용 해시
APIKey string // 평문(DB 저장 전 AES-256-GCM 암호화, BeforeSave/AfterFind 참조)
FullAccess bool // 전체 접근(capabilities 제한 없음)
KnowledgeBaseIDs StringArray // KB allow-list(비어 있음 = 제한 없음)
Capabilities StringArray // 기능 목록
LastUsedAt / ExpiresAt / RevokedAt *time.Time
}- DB 저장 시 암호화:
SYSTEM_AES_KEY가 설정되면BeforeSave훅이api_key열을 AES-GCM으로 암호화하여 저장하고AfterFind가 자동 복호화합니다. 테이블 조회는 항상 비가역적인KeyHash를 사용합니다. - 검증 흐름: 요청에
X-API-Key포함 → 해시 계산 →KeyHash로 테이블 조회 →RevokedAt/ExpiresAt확인 →TenantAPIKeyScope{KeyID, ScopeType, FullAccess, KnowledgeBaseIDs, Capabilities}를 context에 주입. 이후types.TenantAPIKeyScopeFromContext로 읽습니다.
Organization(조직 / 공유 공간)
internal/types/organization.go:
type Organization struct {
ID string
Name / Description / Avatar string
OwnerID string // 생성자 사용자
OwnerTenantID uint64 // 조직을 소유한 테넌트
InviteCode string `gorm:"uniqueIndex"` // 조직 초대 코드
InviteCodeExpiresAt *time.Time
InviteCodeValidityDays int // 0(영구)/1/7/30 허용, 기본값 7
RequireApproval bool // 참여 시 승인 필요
Searchable bool // 검색으로 발견 가능한지 여부
MemberLimit int // 기본값 50
}
type OrganizationTenantMember struct { // 멤버 단위는 "테넌트"
OrganizationID string
TenantID uint64
Role OrgMemberRole // admin / editor / viewer, 기본값 viewer
RepresentativeUserID string // 대표 사용자(정보성 필드)
}
const (
OrgRoleAdmin OrgMemberRole = "admin" // 조직과 공유 리소스 전체 제어
OrgRoleEditor OrgMemberRole = "editor" // 공유 KB 콘텐츠 편집 가능, 조직 설정 변경 불가
OrgRoleViewer OrgMemberRole = "viewer" // 읽기 전용
)구현 참고
아래 경로는 모두 저장소 루트 디렉터리 기준입니다.
| 계층 | 파일 |
|---|---|
| 테넌트 모델 | internal/types/tenant.go |
| 사용자 모델 | internal/types/user.go |
| 테넌트 멤버와 역할 | internal/types/tenant_member.go |
| 테넌트 초대 | internal/types/tenant_invitation.go |
| API Key 모델과 기능 | internal/types/tenant_api_key.go |
| 조직 / 공유 모델 | internal/types/organization.go |
| 가입 / 로그인 Handler | internal/handler/auth.go |
| 초대 가입 Handler | internal/handler/auth_register_by_invite.go |
| 멤버 / 초대 / 초대 링크 Handler | internal/handler/tenant_member.go, tenant_invitation.go, tenant_invite_link.go |
| 조직 Handler | internal/handler/organization.go |
| JWT / OIDC / 사용자 서비스 | internal/application/service/user.go |
| 조직 / KB 공유 서비스 | internal/application/service/organization.go, kbshare.go |
| RBAC 미들웨어 | internal/middleware/rbac.go |
| RBAC 라우트 가드 매트릭스 | internal/router/rbac.go |
| 인증 설정 | internal/config/config.go(AuthConfig / OIDCAuthConfig / TenantConfig) |
