본문으로 바로 가기

테넌트, 사용자와 인증·인가

작업 공간은 WeKnora의 리소스 및 권한 경계이며, 지식 베이스, 모델, 에이전트, 세션과 스토리지 할당량은 모두 공간에 속합니다. 사용자는 여러 공간에 참여하고 공간마다 다른 역할을 가질 수 있습니다. 조직은 여러 공간을 연결하여 지식 베이스와 에이전트를 공유하는 데 사용됩니다. 백엔드에서는 작업 공간을 Tenant로 표현합니다.

멤버 초대, 리소스 공유와 API 연동 진입점은 다음과 같습니다. 전체 배포 환경을 관리하려면 별도의 플랫폼 권한이 필요합니다.

작업진입점과 요구 사항
팀 멤버 초대공간 설정 → 멤버 → 초대에서 상대에게 역할 부여(Owner / Admin / Contributor / Viewer)
다른 팀에 지식 베이스 공유조직 생성 → 두 공간을 모두 추가 → 지식 베이스에서 「조직에 공유」
API 연동공간 설정 → API Key에서 필요한 기능 선택(검색 / 질의응답 / 수집 / 관리), 필요하면 접근 가능한 지식 베이스 제한
전체 배포 환경 관리(전역 설정, 작업 큐, 공간 간 감사)공간 Owner와 별도로 부여되는 시스템 관리자 자격 필요. 플랫폼 관리와 시스템 관리자 참조
전체 공간 삭제공간 설정에서 Owner가 실행(DELETE /tenants/:id). 해당 공간의 지식 베이스, Agent, 세션과 멤버 관계가 함께 삭제되며 되돌릴 수 없음
스크린샷 준비 중
공간 멤버 관리: 멤버 역할과 초대 진입점

멤버 목록, 역할 드롭다운과 「멤버 초대」 버튼을 보여주세요. pending 초대를 하나 포함하면 좋아요.

website-docs/public/screenshots/settings-members.png
공간 멤버 관리: 멤버 역할과 초대 진입점

Viewer는 열람과 질문, Contributor는 지식 베이스 생성과 문서 업로드, Admin은 멤버와 공간 설정 관리가 가능하며, Owner는 공간 삭제나 양도도 가능합니다. 리소스 수정에는 소유권 또는 공유 권한 제약도 적용됩니다. 역할 매트릭스는 참고 섹션을 확인하세요.

사용자는 비밀번호 또는 OIDC 싱글 사인온으로 로그인하고, 프로그램은 API Key로 접근합니다. 로그인한 사용자의 권한은 공간 역할과 리소스 소유 관계로 결정되며, API Key는 부여된 기능과 지식 베이스 범위에 따라 검증됩니다.

멤버 초대와 역할 부여

공간 설정의 「멤버」에서 사용자를 초대하고 업무 범위에 따라 역할을 부여합니다. 초대받은 사용자는 수락 후 현재 공간에 참여합니다. 공개 가입이 꺼져 있어도 유효한 초대로 가입할 수 있습니다. 기존 사용자도 두 번째 계정을 만들 필요 없이 초대를 수락하여 새 공간에 참여할 수 있습니다.

공간 멤버 역할과 조직 멤버 역할은 별도로 관리됩니다. 자료를 공유할 때는 지식 베이스의 공유 권한, 수신 공간의 조직 내 역할, 사용자 자신의 공간 역할을 함께 확인해야 합니다.

지식 베이스와 에이전트 공유

먼저 원본 공간과 수신 공간을 같은 조직에 참여시킨 다음, 권한이 있는 멤버가 지식 베이스나 에이전트를 해당 조직에 공유합니다. 수신자의 실제 권한은 공유 기록과 멤버 역할의 제약을 함께 받으며, 조직 공유가 사용자의 공간 역할을 자동으로 높이지는 않습니다.

프로그램용 API Key 설정

공간 설정에서 API Key를 생성하고 작업에 따라 검색, 질의응답, 수집 또는 관리 기능을 선택합니다. 자료 범위를 제한하려면 접근 가능한 지식 베이스도 지정합니다. 프로그램은 X-API-Key 요청 헤더로 자격 증명을 전달합니다. 구체적인 기능과 리소스 제한은 참고 섹션을 확인하세요.

공간 및 플랫폼 권한 관리

공간 삭제는 Owner가 실행하며, 해당 공간의 지식 베이스, 에이전트, 세션과 멤버 관계가 삭제됩니다. 전역 설정, 플랫폼 작업 큐와 공간 간 감사는 시스템 관리자가 관리합니다. 자세한 내용은 플랫폼 관리와 시스템 관리자를 참고하세요.

개념 개요

핵심 사항:

  • 하나의 User는 tenant_members 테이블을 통해 여러 Tenant에 동시에 속할 수 있으며, 각 멤버 관계는 독립된 역할을 가집니다.
  • 조직 멤버 관계는 테넌트 단위입니다(OrganizationTenantMembertenant_id 단위). 공유 역시 "특정 테넌트가 특정 조직에 KB를 공유"하는 방식입니다.
  • API Key는 JWT 사용자와 완전히 독립된 머신 주체로, 테넌트 역할 계층을 재사용하지 않습니다.

인증과 인가 참고

가입과 로그인

가입 모드(invite-only)

internal/handler/auth.go + internal/config/config.go:

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_settingsauth.registration_mode 행 > 위에서 구성한 cfg 값 > 하드코딩된 최종 기본값 self_serve 순입니다. DISABLE_REGISTRATION은 요청마다 다시 읽지 않습니다.

따라서 시스템 관리자가 UI에서 auth.registration_modeself_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 모드여도 가능합니다.

go
// 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/mecapabilities.auto_accept_invitation으로 이 스위치를 인식합니다. 임의의 공유 링크를 로그인 없는 진입점으로 바꾸는 기능은 아닙니다.

테넌트 멤버, 초대와 초대 링크

멤버 관리와 대상 지정 초대

Handler: internal/handler/tenant_member.go, tenant_invitation.go. /tenants/:id 그룹에는 공통으로 PathTenantMatch()가 적용됩니다(URL의 테넌트가 token의 활성 테넌트와 같아야 하며 슈퍼유저는 예외).

엔드포인트최소 역할설명
GET /tenants/:id/membersVieweractive 멤버를 페이지 단위로 조회, q로 이메일/사용자 이름 부분 일치 필터링
POST /tenants/:id/membersOwner기존 사용자 직접 추가 {email, role}
PUT /tenants/:id/members/:user_idOwner역할 변경
DELETE /tenants/:id/members/:user_idOwner멤버 제거
POST /tenants/:id/invitationsOwner기존 사용자 대상 지정 초대 {email, role, message}
GET /tenants/:id/invitationsViewer초대 목록 조회
DELETE /tenants/:id/invitations/:inv_idOwner초대 철회
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_revokedinternal/types/audit_log.go).

internal/handler/tenant_invite_link.go. 대상 지정 초대와 같은 테이블에 저장합니다. InviteeUserID가 비어 있으면 공유 링크(여러 명 사용 가능, AcceptedCount로 집계), 비어 있지 않으면 대상 지정 초대입니다.

  • POST /tenants/:id/invite-links(Owner): {role, message}invite_url 반환({FrontendBaseURL}/register?token=.... FrontendBaseURL은 YAML frontend_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.goisAdmin || isOwner 판정).

초대 검색: 사용자가 아닌 공간(테넌트) 단위

조직 초대는 작업 공간을 대상으로 합니다. GET /organizations/:id/search-tenants는 공간 이름으로 검색하며 조직 admin만 호출할 수 있습니다. 사용자는 여러 공간에 속할 수 있으므로 검색 결과는 공간 후보를 반환합니다.

go
// 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:

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, 권한 변경 / 공유 철회에 사용):

  1. 호출자가 원래 공유한 사람인 경우(같은 user id);
  2. 호출자 테넌트가 원본 테넌트이고 호출자가 테넌트 Admin+인 경우(소유권은 테넌트 단위이므로 원래 공유한 사람이 떠나도 테넌트 Admin이 계속 관리 가능);
  3. 호출자 테넌트가 대상 조직의 admin인 경우(원래 공유한 사람이 떠난 후 org admin이 공유를 복구 가능).

유효 권한 = 여러 계층의 교집합(최솟값 적용):

go
// 최종 권한 = 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.gorbacGuards로 모입니다.

  1. 역할 가드(role-only): Viewer() / Contributor() / Admin() / Owner() / SystemAdmin(). "호출자의 현재 테넌트 역할은 무엇인가"를 확인합니다.
  2. 소유권 가드(ownership-or-role): OwnedKBOrAdmin() 등. "호출자가 이 리소스의 생성자이거나 최소 Admin+인가"를 확인합니다.
  3. 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.CreatorIDKB 업데이트 / 삭제 / pin / 지식 업로드 / 태그 CRUD
OwnedKBOrAdminFromKbIDParam:kbId → KB.CreatorID/initialization/* KB 설정 라우트
OwnedAgentOrAdmin:id → Agent.CreatorID(내장 Agent의 creator는 비어 있으며 Admin+만 변경 가능)Agent 변경
OwnedKnowledgeKBOrAdminknowledge :id → 소속 KB.CreatorID지식 업데이트 / 삭제 / 재파싱 / 이미지 편집
OwnedChunkKBOrAdmin / ...FromChunkID:knowledge_id 또는 chunk :id → KB.CreatorIDchunk 변경
OwnedWikiKBOrAdmin:kb_id → KB.CreatorIDWiki 페이지 CRUD

하위 리소스는 부모 KB의 접근 제어를 상속해야 합니다(주석에서는 FAQ/Tag, agent share, KB share에 잘못된 기준을 연결했던 버그를 수정한 사실을 명시함).

미들웨어 의미(internal/middleware/rbac.go

RequireRole / RequireOwnershipOrRole의 판정 순서:

  1. API Key 주체는 즉시 통과(인가는 라우트 선언 메커니즘의 APIKeyGate에서 처리하며, 합성 시스템 사용자가 creator_id와 일치할 수 없음);
  2. 역할 충족 → 통과;
  3. 테넌트 간 슈퍼유저(IsCrossTenantSuperuser)→ 통과;
  4. RBAC 강제 적용이 꺼짐(tenant.enable_rbac=false, 점진적 적용 모드)→ 로그만 기록하고 통과;
  5. ownership 가드에서 creator 조회: 리소스 없음 → handler가 404를 반환하도록 통과; 조회 실패 → 503; creator == 현재 사용자 → 통과;
  6. 그 외에는 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.gorbac.goKBAccess* 계열로 래핑)는 세 가지 접근 경로를 통합합니다.

text
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_agentsAgent 목록 및 상세 조회(생성·수정 제외)
ingest콘텐츠 쓰기: 문서 업로드, chunk / FAQ / 태그 / Wiki 편집, 지식 일괄 삭제와 이동
manage_kbsKB 생명주기: 생성 / 복사 / 복제본 / 업데이트 / 삭제 / 초기화 설정
manage_agentsAgent 추가·삭제·수정과 복사
message_history테넌트 단위 채팅 기록 검색·조회(POST /messages/search 등, chat과 독립적)
manage_models모델 정의와 자격 증명 관리
manage_mcp_servicesMCP 서비스와 자격 증명 관리
manage_datasources데이터 소스 커넥터와 동기화 작업 관리
manage_channelsEmbed / 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가 유일한 기준 정보).

go
// 정책 생성자
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/loadchat
GET /agents, GET /agents/:id, GET /agents/:id/suggested-questionsread_agents
POST/PUT/DELETE /agents, POST /agents/:id/copymanage_agents
PUT/DELETE /knowledge-bases/:id, POST /initialization/initialize/:kbIdmanage_kbs
POST /messages/search, GET /messages/chat-history-statsmessage_history(chat이 아님)
GET /system/admin/settingsplatform key + system_settings_read
POST /system/admin/runtime/queues/:queue/tasks/:task_id/actions/:actionplatform key + system_runtime_manage

KB Allow-list

KnowledgeBaseIDs가 비어 있지 않으면 key는 목록 내 KB에만 접근할 수 있습니다(knowledge_api_key_scope_test.go에서 확인).

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.goOIDCAuthConfig:

설정 항목설명
enableOIDC 활성화 여부
issuer_url예상 Issuer, id_token 검증에 사용
jwks_uri서명 공개 키 집합 주소. 환경 변수 OIDC_AUTH_JWKS_URI
discovery_urlOpenID 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 / .emailclaims 필드 매핑(기본값 name / email

인가/Token 엔드포인트는 명시적으로 설정할 수 있습니다. 두 항목을 모두 입력했더라도 issuer 또는 jwks_uri가 불완전하면 discovery로 검증 정보를 보완해야 합니다. 신뢰할 수 있는 검증 설정이 없을 때 id_token의 페이로드만 파싱해서 로그인할 수는 없습니다.

라우트(internal/router/router.go):

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_modeself_serve / invite_onlyself_serve공개 가입 스위치(DB system_settings에서 실시간 변경 가능)
auth.default_tenant_modecreate_personal / tenantlesscreate_personal신규 사용자의 개인 테넌트 자동 생성 여부
tenant.enable_rbactrue / falsetrueRBAC 강제 적용 / 로그 전용 모드
JWT_SECRET(환경 변수)임의 문자열랜덤 32바이트JWT HMAC 키
SYSTEM_AES_KEY(환경 변수)AES 키미설정API Key 평문 DB 저장 시 암호화
oidc.*설정 참조꺼짐OIDC 싱글 사인온
frontend_base_url / FRONTEND_BASE_URLURL상대 경로초대 링크의 가입 페이지 주소
Tenant.StorageQuota바이트10737418240(10GB)테넌트 스토리지 할당량

JWT 메커니즘

internal/application/service/user.go에 구현되어 있으며 github.com/golang-jwt/jwt(HMAC-SHA256)를 사용합니다.

키 출처

go
func getJwtSecret() string {
    // 1) 환경 변수 JWT_SECRET
    // 2) 없으면 시작 시 32바이트 보안 랜덤 키 생성(Base64), 프로세스 재시작 후 기존 token 무효화
}

발급(Access + Refresh 이중 token)

go
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 Token24시간user_id / email / tenant_id / type=access
Refresh Token7일user_id / type=refresh(tenant_id 없음)

두 token 모두 서버 측 철회를 위해 auth_tokens 테이블에 기록합니다.

검증과 갱신

ValidateToken의 검사 흐름:

  1. 서명 알고리즘은 HMAC 계열이어야 합니다(알고리즘 혼동 공격 방지);
  2. type=refresh인 token은 access token으로 사용할 수 없습니다isRefreshTokenClaims);
  3. auth_tokens 테이블에서 IsRevoked를 확인합니다(로그아웃 = 기록 철회);
  4. 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:

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:

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.goIsCrossTenantSuperuser()는 설정을 먼저 확인한 뒤 사용자를 확인하며, 설정이 꺼져 있으면 로그인 응답에서도 이 필드를 false로 변경). 적용되면 공간 역할 검사를 우회하여 /tenants/all, /tenants/search 등 공간 간 엔드포인트에 접근할 수 있습니다. POST /tenants(새 공간 생성)는 공간 간 엔드포인트가 아니며, 로그인한 모든 사용자가 호출할 수 있습니다(셀프 생성 정책과 할당량 제한 적용).
  • IsSystemAdmin: 플랫폼 수준 관리자(system admin)로, 모든 테넌트 역할과 독립적이며 /system/admin/* 제어 영역에 사용합니다. 특정 공간이 아니라 전체 배포 환경을 관리합니다. 최초 관리자 생성 방법과 가능한 작업은 플랫폼 관리와 시스템 관리자를 참고하세요.

TenantMember와 테넌트 역할

internal/types/tenant_member.go:

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()
}
go
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:

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:

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
가입 / 로그인 Handlerinternal/handler/auth.go
초대 가입 Handlerinternal/handler/auth_register_by_invite.go
멤버 / 초대 / 초대 링크 Handlerinternal/handler/tenant_member.go, tenant_invitation.go, tenant_invite_link.go
조직 Handlerinternal/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.goAuthConfig / OIDCAuthConfig / TenantConfig

WeKnora v0.8.0 소스 코드 기반 한국어 번역 · MIT License