- Added comprehensive documentation for the role-based access control (RBAC) system within tenants, detailing the purpose, role matrix, enforcement mechanisms, and configuration options. - Explained the implications of enabling RBAC, including the default settings and how to audit role assignments before activation. - Included schema references for the `tenant_members` and `audit_logs` tables to assist developers in understanding the underlying data structure.
16 KiB
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, defaultfalse). Schema andtenant_membersrows 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+ theenable_cross_tenant_accessflag +X-Tenant-IDheader) get an Admin-level pass into the target tenant without needing a row intenant_members. Used by org-level operators who administer many tenants. - API-key callers (
X-API-Keysynthetic 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_membersrows — 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):
RequireRolemiddleware 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.RequireOwnershipOrRoleshort-circuits before even running its creator-lookup closure, so the dormant rollout window incurs zero extra DB roundtrips on hot mutation paths.audit_logsonly records member-management events (rbac.member_addedetc.). Access-denied rows start landing only when enforcement is on.
When tenant.enable_rbac=true:
- Insufficient role → HTTP 403 + a durable
rbac.access_deniedrow inaudit_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
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 |
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.
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":
- Upgrade.
tenant.enable_rbac=false(default) → schema lands,tenant_membersis backfilled (one Owner per tenant, the rest Contributor),creator_idpopulated on every KB. No behaviour change yet. - 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/DELETEas needed. Every change writes anaudit_logsrow.
- 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. - Flip the flag. Set
tenant.enable_rbac=true(orWEKNORA_TENANT_ENABLE_RBAC=true). Restart the app. From this point on:- Insufficient role → 403.
audit_logsrecords every reject (subject to dedup).
- Optional — enable invite-only. If you've moved off self-serve
registration, set
auth.registration_mode=invite_only. The Register tab disappears from/loginand the server rejectsPOST /auth/registerwith 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_deniedonly writes whentenant.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 testcoversinternal/middleware/rbac_test.go,internal/handler/rbac_lookups_test.go,internal/application/service/audit_log_test.goandinternal/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
TenantRoleandTenantID, so a denied request shows up as a single trace with the role on it — no need to correlate logs and traces by hand.