Files
Enterprise-Knowledge-Base/docs/rbac.md
T
wizardchen ff1f03ce0d feat(rbac): daily retention sweep for audit_logs (default 90 days)
The audit_logs table grows monotonically until something purges it.
PR 6 explicitly listed retention as out of scope for v1; this picks
that up.

Adds an `audit.retention_days` config (default 90, 0 disables) and a
small background goroutine `AuditLogRetentionRunner` that fires once
~10 minutes after boot and then every 24h, calling
`AuditLogService.Purge` which DELETEs rows older than the cutoff in a
single statement. The dependency surface is intentionally minimal —
no robfig/cron, no asynq, no migration — because retention has no
wall-clock alignment requirement.

Wiring is the same shape as the existing data source scheduler:
container `Provide(NewAuditLogRetentionRunner)`, an `Invoke` that
calls `Start`, and a `ResourceCleaner.RegisterWithName` for graceful
shutdown.

Defaults preserve operator intent:
  - `audit:` section omitted from YAML  -> retention_days=90.
  - explicit `audit.retention_days: 0` in YAML  -> purge disabled.
  - `WEKNORA_AUDIT_RETENTION_DAYS=N` env override (incl. N=0).
  - `retention_days < 0` is rejected by ValidateConfig.

Tests cover the service Purge contract (no-op when disabled, cutoff
math, error propagation) and the runner lifecycle (Start no-op when
disabled, idempotent Start/Stop, ticker cadence, runOnce swallows
errors). 10 new tests, all green.

docs/rbac.md updated with the new YAML / env / behaviour.

Refs: #1303
2026-05-18 17:28:58 +08:00

17 KiB
Raw Blame History

Tenant RBAC Guide

How WeKnora enforces who-can-do-what inside a tenant, how to roll the feature out without breaking existing deployments, and how to audit the system once it is on.

Status: shipped behind a feature flag (tenant.enable_rbac, default false). Schema and tenant_members rows are populated on every install; enforcement is opt-in.

Why this exists

Before #1303 every authenticated user with X-API-Key or a JWT was effectively an Admin in their tenant. That's fine for a single-user self-host, but as soon as a tenant has more than one human (e.g. a small team sharing a knowledge base) you need to draw lines around:

  • who may delete a knowledge base or revoke an API key (Admins);
  • who may upload documents and edit their own KB (Contributors);
  • who may only read and ask questions (Viewers).

RBAC layers a per-tenant role matrix on top of the existing JWT / API-key auth so all three states are first-class.

The role matrix

Every tenant member has exactly one role, stored in tenant_members.role:

Role Typical use Notable powers
viewer Read-only consumer (chat, search, browse). None — reads only.
contributor Upload + edit own KBs / agents. Can mutate resources whose creator_id matches their user ID. Cannot touch other contributors' resources.
admin Tenant-wide operator. Can mutate any resource in the tenant; manages members; configures shared infrastructure (Ollama, parser, storage, etc.).
owner Tenant founder. Same as Admin, plus may delete the tenant and cannot be demoted by another Admin. Exactly one Owner per tenant after backfill.

Higher roles inherit lower roles' permissions. The hierarchy is viewer < contributor < admin < owner.

Special cases the auth layer still handles

  • Cross-tenant superusers (User.CanAccessAllTenants + the enable_cross_tenant_access flag + X-Tenant-ID header) get an Admin-level pass into the target tenant without needing a row in tenant_members. Used by org-level operators who administer many tenants.
  • API-key callers (X-API-Key synthetic users) are pinned to Admin in the tenant the key belongs to. This preserves every scripted-integration use case except tenant deletion (which still requires Owner).
  • Orphan tenants (zero tenant_members rows — typically API-key-only tenants) auto-promote the first authenticating human to Owner. Prevents lock-out after a fresh install.

How enforcement is gated

Two routes a request can take:

                           ┌─────────────────────┐
JWT / API-key ──► auth ──► │  tenant_members     │
                           │  lookup → role      │
                           └─────────┬───────────┘
                                     │
                            ┌────────┴────────┐
                            │ EnableRBAC?     │
                            └────────┬────────┘
                                     │
            ┌────────────────────────┴────────────────────────┐
            │                                                 │
            ▼                                                 ▼
  ┌──────────────────────┐                          ┌──────────────────────┐
  │   false (default)    │                          │   true               │
  │   role logged but    │                          │   role enforced;     │
  │   not enforced;      │                          │   denials emit a 403 │
  │   ownership lookups  │                          │   AND a durable row  │
  │   skipped entirely   │                          │   in audit_logs      │
  └──────────────────────┘                          └──────────────────────┘

When tenant.enable_rbac=false (the shipped default):

  • RequireRole middleware logs the would-be reject and lets the request through ([rbac] role insufficient (logged but not enforced) ...). Use these logs to audit your role assignments before flipping the flag.
  • RequireOwnershipOrRole short-circuits before even running its creator-lookup closure, so the dormant rollout window incurs zero extra DB roundtrips on hot mutation paths.
  • audit_logs only records member-management events (rbac.member_added etc.). Access-denied rows start landing only when enforcement is on.

When tenant.enable_rbac=true:

  • Insufficient role → HTTP 403 + a durable rbac.access_denied row in audit_logs (subject to a 1-minute sliding-window dedup so probing clients can't fill the table).
  • Ownership lookup runs and decides whether the caller is the resource creator. Genuine "row missing" (404) is surfaced as 404, not 403, so client diagnostics still work.

Configuration

YAML (config/config.yaml):

tenant:
  # Default false. Flip to true once role assignments have been audited.
  enable_rbac: false
  # Optional: leaves the existing cross-tenant superuser flag in place.
  enable_cross_tenant_access: false

auth:
  # self_serve (default) — anyone may register; new tenant + Owner
  #                       membership auto-created.
  # invite_only          — public registration is rejected; new users
  #                       enter only via /tenants/:id/members invitations.
  registration_mode: self_serve

audit:
  # Days of audit history retained. A daily background sweep deletes
  # rows older than this. Default 90 (set automatically when the
  # `audit:` section is omitted from the YAML); set to 0 to disable
  # the purge entirely (the table grows monotonically).
  retention_days: 90

Environment overrides (always win over YAML):

Env var YAML key Values
WEKNORA_TENANT_ENABLE_RBAC tenant.enable_rbac true / false
WEKNORA_AUTH_REGISTRATION_MODE auth.registration_mode self_serve / invite_only
WEKNORA_AUDIT_RETENTION_DAYS audit.retention_days non-negative integer

The startup logger emits one line summarising both effective values plus their override sources, so you can confirm at boot which mode the deployment is in.

Schema reference

tenant_members

CREATE TABLE tenant_members (
    id          BIGSERIAL PRIMARY KEY,
    user_id     VARCHAR(36) NOT NULL,
    tenant_id   BIGINT      NOT NULL,
    role        VARCHAR(32) NOT NULL,  -- owner | admin | contributor | viewer
    status      VARCHAR(32) NOT NULL DEFAULT 'active',
    joined_at   TIMESTAMP WITH TIME ZONE,
    created_at  TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_tenant_members_user_tenant_unique
    ON tenant_members(user_id, tenant_id) WHERE status = 'active';

A user belongs to a tenant by having an active row in this table. The (user_id, tenant_id) uniqueness is conditional on status = 'active' so historical revoked rows do not block a re-invite.

Per-resource ownership

Migration 000043 added two columns the role check leans on:

  • knowledge_bases.creator_id VARCHAR(36) — backfilled to the tenant Owner for legacy rows. Empty string / NULL means "tenant-owned, no human creator" and only role ≥ min may mutate.
  • custom_agents.runnable_by_viewer BOOLEAN — when true (default), Viewers can run the agent in chat without a role bump.

Sub-resources resolve up the ownership chain:

chunk_id ─► knowledge_id ─► kb_id ─► knowledge_bases.creator_id

Same for FAQ entries, generated questions, tags, and wiki pages.

audit_logs

CREATE TABLE audit_logs (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT       NOT NULL,
    actor_user_id   VARCHAR(36)  NOT NULL DEFAULT '',
    actor_role      VARCHAR(32)  NOT NULL DEFAULT '',
    action          VARCHAR(64)  NOT NULL,
    target_type     VARCHAR(32)  NOT NULL DEFAULT '',
    target_id       VARCHAR(64)  NOT NULL DEFAULT '',
    target_user_id  VARCHAR(36)  NOT NULL DEFAULT '',
    request_path    VARCHAR(512) NOT NULL DEFAULT '',
    request_method  VARCHAR(16)  NOT NULL DEFAULT '',
    outcome         VARCHAR(16)  NOT NULL DEFAULT 'success',
    details         JSONB        NOT NULL DEFAULT '{}'::JSONB,
    created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

Built-in actions today:

Action Outcome When
rbac.member_added success POST /tenants/:id/members succeeds
rbac.member_removed success DELETE /tenants/:id/members/:user_id succeeds
rbac.member_role_changed success PUT /tenants/:id/members/:user_id succeeds
rbac.member_left success POST /tenants/:id/members/leave succeeds
rbac.access_denied denied RequireRole / RequireOwnershipOrRole rejects (only when enforcement is on)

The schema is intentionally generic so future PRs can add KB / agent / chunk action constants without another migration.

A daily background goroutine (AuditLogRetentionRunner, service/) sweeps rows older than audit.retention_days. The first sweep fires ~10 minutes after boot to stay out of the way of startup traffic; the loop then runs every 24 h. The runner short-circuits when retention is 0, so disabling it costs zero DB round-trips.

Route guards

Centralised in internal/router/rbac.go as rbacGuards:

Guard What it requires
g.Viewer() Tenant member, any role
g.Contributor() role ≥ Contributor
g.Admin() role ≥ Admin
g.Owner() role = Owner
g.OwnedKBOrAdmin() KB.creator_id == caller, OR Admin+
g.OwnedKBOrAdminFromKbIDParam() Same, but reads :kb_id from a non-:id param
g.OwnedAgentOrAdmin() CustomAgent.creator_id == caller, OR Admin+
g.OwnedKnowledgeKBOrAdmin() Resolves :knowledge_id → KB.creator_id
g.OwnedChunkKBOrAdmin() Resolves :chunk_id → knowledge → KB.creator_id
g.OwnedChunkKBOrAdminFromChunkID() Same chain, but starts from a different param name
g.OwnedWikiKBOrAdmin() Resolves wiki page → KB.creator_id
g.PathTenantMatch() URL :tenant_id matches the auth context
g.CrossTenant() Caller has cross-tenant access

Read-only endpoints stay on g.Viewer(). Anything that mutates a shared infrastructure resource (Ollama install, parser/storage check, WeKnora Cloud credentials, web-search providers, vector stores, chat-history config) goes on g.Admin(). Anything per-resource picks the matching Owned*OrAdmin guard.

Rollout playbook

The same playbook a self-host operator (or the Tencent/WeKnora repo itself) should follow when promoting a deployment from "logged" to "enforced":

  1. Upgrade. tenant.enable_rbac=false (default) → schema lands, tenant_members is backfilled (one Owner per tenant, the rest Contributor), creator_id populated on every KB. No behaviour change yet.
  2. Audit the membership. Call GET /api/v1/tenants/:id/members (Admin+) and confirm:
    • Exactly one Owner per tenant.
    • The right humans are Contributors, Viewers, etc. (matters most in tenants where many users share an API key today and got auto-promoted to Contributor by the backfill).
    • Adjust with PUT /api/v1/tenants/:id/members/:user_id / DELETE as needed. Every change writes an audit_logs row.
  3. Watch the dormant logs. Tail the API logs for [rbac] role insufficient (logged but not enforced) lines. These tell you exactly which production calls would 403 if you flipped the flag now. Fix any user role that produces unexpected denials.
  4. Flip the flag. Set tenant.enable_rbac=true (or WEKNORA_TENANT_ENABLE_RBAC=true). Restart the app. From this point on:
    • Insufficient role → 403.
    • audit_logs records every reject (subject to dedup).
  5. Optional — enable invite-only. If you've moved off self-serve registration, set auth.registration_mode=invite_only. The Register tab disappears from /login and the server rejects POST /auth/register with 403.

Rollback

If enforcement causes unexpected breakage:

# Flip the flag back via env (no restart-with-rebuild needed):
export WEKNORA_TENANT_ENABLE_RBAC=false
# Restart the app.

Membership rows and creator_id columns stay populated so re-enabling later doesn't require another backfill. Migration 000043 / 000044 both ship down.sqls, but rolling those back drops tenant_members and audit_logs entirely — only do that if you intend to remove the feature for good.

Frontend behaviour

The Pinia auth store exposes:

  • authStore.currentTenantRole — '' until membership resolves, then one of the four roles. Use the empty string as a "loading" signal; flashing a privileged button before the role is known is worse UX than waiting.
  • authStore.hasRole('admin') etc. — convenience helpers that walk the hierarchy.

Every mutation surface in the UI is gated either by a role check or by a per-resource ownership predicate (isOwner computed off kb.creator_id === authStore.user?.id). This is the matching pair to the backend guard — when a button would 403 on click, we hide the button instead of letting the user discover it through a failed request.

Common questions

"I upgraded and now everyone is a Contributor instead of an Admin."

The backfill picks the earliest active user in each tenant as that tenant's Owner; everyone else becomes Contributor. If a single human-shared account or API key created the tenant, you may need to demote bots and re-promote your real Admin via PUT /api/v1/tenants/:id/members/:user_id.

"I flipped the flag and a script started getting 403."

Likely the script authenticates as a Viewer / Contributor instead of the Admin you assumed. Check the JWT user, look up the matching tenant_members.role, promote if appropriate. Or — if the script should be tenant-wide — switch it to authenticate via X-API-Key, which is pinned to Admin.

"Why is the audit log not catching some 403s?"

Two reasons:

  • Sliding-window dedup. The same (actor, path, action) tuple writes at most one durable row per minute. The full reject series is still in the perishable application log ([rbac] role insufficient ...).
  • Enforcement off. rbac.access_denied only writes when tenant.enable_rbac=true. The dormant mode just emits the warning log; member-management events do still write durably.

"Can I have per-route ACL more granular than role + creator?"

Not in v1. The matrix is intentionally a small fixed lattice (Viewer < Contributor < Admin < Owner) with one ownership escape hatch per resource. Anything finer (e.g. "viewer can see audit log for their own actions") is a follow-up.

"Where do I see the audit log in the UI?"

Settings → Members → Audit Log tab (Admin+ only). Cursor-paginated chronological feed with action / outcome chips. Filter UI is a v2.

Testing & observability

  • make test covers internal/middleware/rbac_test.go, internal/handler/rbac_lookups_test.go, internal/application/service/audit_log_test.go and internal/middleware/rbac_audit_test.go (~25 cases).
  • e2e smoke under both flag values is documented in #1303 PR series. The matrix that ships green: each role × each guarded route → expected status code.
  • Langfuse / OTel spans carry the resolved TenantRole and TenantID, so a denied request shows up as a single trace with the role on it — no need to correlate logs and traces by hand.