When an Owner of tenant A invited user B as a Viewer, B could log in,
switch the active tenant from B's home to A via the user menu, and
still see every write button — Create KB, Upload Document, Edit/Delete
agent — because the role-gate plumbing on the client was wired to the
wrong tenant. The server's RBAC enforcement (PR 2 / PR 5) still blocked
the actual mutations with 403, so no data leaked, but the UI presented
authority the user did not have, which is a confusing and embarrassing
defect.
Five distinct root causes, all on the client / server boundary:
1. `currentTenantRole` (frontend/src/stores/auth.ts) read
`tenant.value.id` — the user's *home* tenant — instead of the active
tenant id. After switching to A, the helper still returned B's home
role (Owner), so every `hasRole(...)` gate in the codebase silently
short-circuited. Fix: read `selectedTenantId` first, fall back to
`tenant.id`.
2. `setMemberships` was only called once, on /auth/login. Hard reloads
(which the tenant switcher triggers) re-ran `getCurrentUser()` via
`App.vue` and `router/index.ts`, but those callers discarded the
`memberships` field of the response, so the auth store kept the
login-time snapshot forever. Role changes between sessions stayed
invisible. Fix: extract memberships in both /auth/me consumers and
feed them into `setMemberships`.
3. `KnowledgeBase.vue`'s `isOwner` compared `kb.tenant_id` to the
active tenant id, so any KB inside the current tenant was treated
as "owned by the caller" — bypassing every role gate below. Fix:
compare against `kb.creator_id` (the field PR 5 introduced) and
require an actual creator match. `canEdit` / `canManage` now layer
`hasRole('admin')` on top so a non-creator with sufficient role can
still manage.
4. `KnowledgeBaseList.vue` and `AgentList.vue` rendered the per-card
more-menu (Settings, Delete, Edit) unconditionally. With the
ownership column from PR 5 this is straightforward to gate on the
client: Settings/Delete now require creator-match or `hasRole('admin')`
per the server's `OwnedKBOrAdmin` / `OwnedAgentOrAdmin` matrix.
5. The axios interceptor (frontend/src/utils/request.ts) had a
"selectedTenantId === defaultTenantId → don't attach X-Tenant-ID"
short-circuit. Any code that wrote the active tenant into
`weknora_tenant` (UserMenu's loadUserInfo, OIDC sync, router
hydrate) made the two values equal, after which the header silently
stopped attaching and subsequent navigation requests landed on the
home tenant. Fix: drop the short-circuit; always attach when
selectedTenantId is set.
Server-side, /auth/me read `user.TenantID` (the immutable home tenant
id stamped at signup) when fetching the tenant for the response, so
the `tenant` field always pointed at home even after the auth
middleware had already resolved an active tenant from X-Tenant-ID.
The frontend then re-keyed `authStore.tenant` to the home tenant on
every refresh, undoing the switch. Fix: read the active tenant id from
context (`types.TenantIDFromContext(ctx)`) and only fall back to
user.TenantID when context is unset.
The server-side enforcement was correct throughout — every fix here is
purely about not lying to the user with the rendered UI.
Security
- Reject URL :id when it does not match the caller's active tenant on
every /tenants/:id/members/* and /tenants/:id/leave endpoint. Without
this an Owner of tenant A could drive operations on tenant B simply
by changing the URL, because RequireRole resolves the caller's role
against the active tenant from context, not the URL param. The
cross-tenant superuser carve-out mirrors middleware/rbac.go
(CanAccessAllTenants + EnableCrossTenantAccess required).
- AddMember no longer records the synthetic "system-<tenantID>" caller
(X-API-Key auth path) as invited_by, matching the same filter
KB.CreatorID got in PR 2.
- AuthHandler now logs a loud one-shot error when constructed with a
nil/incomplete config so a wiring bug that silently disables the
invite_only gate is visible at boot.
Race fixes
- New repo helpers DemoteOwnerAtomically / RemoveOwnerAtomically wrap
the count-other-owners + write step in a transaction that holds
SELECT … FOR UPDATE locks on every other active Owner row. The
service routes Owner demotion / Owner removal through them, closing
the TOCTOU window where two concurrent demotes of two different
Owners could each observe count=2 and both commit, leaving the
tenant ownerless.
Performance
- ListMembers hydration now batches user lookups via a new
GetUsersByIDs interface method (one SELECT … WHERE id IN (…)
instead of N round-trips). Failure degrades to "no email/username"
rather than dropping rows.
Frontend
- canManage also fires for cross-tenant superusers
(authStore.canAccessAllTenants), mirroring the backend carve-out so
the UI controls match what the server actually allows.
Tests
- Cross-tenant URL guard regression cases for List / Add / Update /
Remove / Leave.
- Superuser bypass tests (flag on -> service reached, flag off -> 403).
- Batched lookup test ensures GetUsersByIDs is called exactly once and
GetUserByID is never used as a fallback.
- Synthetic caller invited_by filter regression test.
- Service-layer atomic last-Owner regression tests for both
UpdateRole and RemoveMember.
Wire the human-facing surface for the tenant-RBAC system that PR 1
introduced and PR 2 enforced: REST endpoints + UI for managing tenant
members, plus the long-pending invite_only registration gate.
Backend
- internal/handler/auth.go: Register now rejects with 403 when
auth.registration_mode=invite_only. PR 1 already exposes the mode via
/auth/config so the frontend hides the sign-up link; this is the
server-side enforcement for direct API calls.
- internal/handler/tenant_member.go: new TenantMemberHandler with
ListMembers / AddMember / UpdateMemberRole / RemoveMember. Maps
service-layer sentinels onto HTTP semantics (ErrLastOwner -> 409,
ErrMembershipAlreadyExists -> 409, ErrUserNotFound on AddMember -> 404,
ErrInvalidTenantRole -> 400). AddMember requires the invitee to
already have an account; SMTP-driven email invites are deferred.
- internal/types/tenant_member.go: new TenantMemberResponse projection
carrying the fields the management UI renders (email, username,
avatar, role, joined_at).
- internal/router/router.go: four routes under /tenants/:id/members
gated per the matrix (Viewer+ for list, Owner+ for every mutation).
- internal/container/container.go: dig provider for the new handler.
Tests
- handler/tenant_member_test.go: 12 table-driven cases covering happy
path, dangling-user tolerance, ErrLastOwner -> 409, ErrMembershipNotFound
-> 404, ErrMembershipAlreadyExists -> 409, unknown email -> 404,
invalid role rejected before user lookup, bad tenant id -> 400.
- handler/auth_register_invite_only_test.go: invite_only -> 403,
self_serve -> 201, nil Auth config falls through.
Frontend
- src/api/tenant/members.ts: typed client (TenantRole/TenantMember/...)
for the four endpoints.
- src/views/settings/TenantMembers.vue: members table with add dialog,
inline role select, confirm-remove modal. All mutation widgets gated
behind currentTenantRole === 'owner'; server still enforces.
- src/views/settings/Settings.vue: register the Members tab; show only
when currentTenantRole is loaded (avoids tab flash on cold nav).
- i18n in en-US/zh-CN/ko-KR/ru-RU: new tenantMember namespace covering
all UI strings and structured error messages (lastOwner / userNotFound
/ alreadyMember / invalidRole etc).
This completes the #1303 RBAC rollout. With cfg.Tenant.EnableRBAC=true
operators now have a full management surface; with EnableRBAC=false
the routes still exist but mutations log-and-pass per PR 2 contract.
POST /auth/switch-tenant mints an access token whose `tenant_id` claim
points to the target tenant, but the middleware previously discarded
that claim entirely:
user, err := userService.ValidateToken(ctx, token) // returned only *User
targetTenantID := user.TenantID // always the home tenant
The session therefore stayed scoped to the user's home tenant despite
the supposedly fresh token, making the new endpoint a no-op once
the request hit any tenant-scoped query.
This change widens the contract:
- UserService.ValidateToken now returns (user, activeTenantID, error)
where activeTenantID is the JWT's `tenant_id` claim, falling back to
user.TenantID when the claim is absent (backward compatibility with
pre-RBAC tokens still in the wild).
- middleware/auth.go uses activeTenantID as the default target before
applying any X-Tenant-ID header override, and recomputes
crossTenantSwitch from `activeTenantID != user.TenantID` so the
resolveTenantRole branches stay correct for switch-tenant sessions.
- The non-middleware /auth/validate handler in handler/auth.go absorbs
the new return value (it only cares about user identity).
Tests:
- internal/application/service/user_jwt_test.go pins the JWT->tenant-id
projection (`tenantIDFromClaims`) — claim present beats fallback,
zero/negative/string claims fall back, int64/uint64 are accepted for
test-built tokens.
Required for PR 2 multi-tenant integration tests, where a single user
needs to switch between Owner-in-A and Viewer-in-B sessions and observe
distinct authority on each side.
Tighten correctness, security and observability of the tenant RBAC
foundation introduced in 3c4cdd70 in response to internal review.
Bugfixes
- custom_agents.runnable_by_viewer: drop the GORM `default:true` tag.
GORM treats a plain bool's zero value as "unspecified" and silently
rewrites it to the column default on insert, which would prevent
admins from ever creating an agent with viewer-runtime disabled. The
column-level DEFAULT TRUE on the database side still backstops rows
inserted by raw SQL / migrations.
- TenantMemberRepository.HasAnyMembers: replace `Limit(1).Count(&n)`
with `Take(&probe)`. GORM's Count ignores Limit and scans the whole
table, which matters because this runs on the auth middleware's
hot path for users without a cached membership.
- service/user.LoginWithOIDC: include User, Tenant and Memberships in
the OIDCCallbackResponse so the OIDC flow returns the same shape as
password login (previously User and friends were left nil).
Security
- middleware/auth.resolveTenantRole: restrict orphan-tenant
auto-promotion to the caller's home tenant. Cross-tenant superuser
visits and JWTs minted for foreign tenants no longer trigger silent
Owner grants. Two regression tests pin the new behaviour.
- service/user.synthFallbackMembership: default the synthesised role
to Viewer instead of Owner. The login response only feeds UI; the
backend re-derives the real role from tenant_members on every
request, so showing a Viewer UI is preferable to a misleading Owner
UI that would surface admin controls the backend will then 403.
- migrations/versioned/000043_tenant_rbac.down.sql: gate the destructive
drop behind a `weknora.allow_destructive_migration` GUC so an
accidental `migrate down` cannot wipe role data in production.
- frontend/src/stores/auth.ts: comment currentTenantRole as
rendering-only so future PRs cannot mistake the localStorage-backed
value for an authorisation signal.
API contract
- Memberships in LoginResponse and OIDCCallbackResponse now serialises
without omitempty, and buildMembershipsForUser / synthFallbackMembership
always return a non-nil slice — frontend can rely on JSON `[]`.
- handler/auth.GetCurrentUser includes memberships so /auth/me refreshes
recover currentTenantRole after a page reload.
Performance
- Add TenantService.GetTenantsByIDs (with the matching repo method) and
use it inside buildMembershipsForUser to batch tenant-name lookups
instead of issuing one query per membership.
Observability
- Replace the remaining `log.Printf` calls in middleware/auth with
contextual logger.Warnf / logger.Infof so role resolution events ride
the standard request-id / Langfuse trace plumbing. The successful
auto-promote line is tagged `[audit]` for log mining.
Tests
- internal/application/service/tenant_member_test.go: hand-rolled
fakeTenantMemberRepo plus 11 cases covering AddMember validation,
EnsureOwner idempotence, "cannot demote/remove the last Owner",
same-role no-op, ErrInvalidTenantRole / ErrMembershipNotFound, and
the TenantRole.HasPermission lattice.
- internal/middleware/auth_role_test.go: fakeMemberService plus 8 cases
pinning every branch of resolveTenantRole, including the two H1
regressions (cross-tenant superuser must not auto-promote; orphan
auto-promote requires the home tenant).
Lay the foundation for tenant-level RBAC without enforcing it yet.
Schema, types, middleware lookup, and login-flow shape changes ship now;
RequireRole/route annotations and member-management endpoints follow in
PR 2 and PR 3.
Highlights:
- New tenant_members table (Postgres + SQLite) with (user_id, tenant_id)
unique key and (owner|admin|contributor|viewer) role values. Migration
000043 backfills one row per existing user, marking the earliest-created
active user in each tenant as Owner and the rest as Contributor.
- KnowledgeBase.creator_id and CustomAgent.runnable_by_viewer added so
PR 2 can wire ownership-based and Viewer-runtime checks.
- Auth middleware loads the active TenantMember row, sets a new
TenantRoleContextKey, and falls back via three escape hatches:
cross-tenant superusers (CanAccessAllTenants) get Admin in the target
tenant; orphan tenants (zero members) auto-promote the first authenticating
human to Owner; and when tenant.enable_rbac is false (the v1 default)
unmatched lookups fail open with Admin to preserve current behaviour.
- Register / OIDC provisioning now insert an Owner membership for the
registrant via TenantMemberService.EnsureOwner.
- LoginResponse renamed tenant -> active_tenant and gained a memberships
array. POST /auth/switch-tenant issues a fresh token pair scoped to a
target membership; GET /auth/config exposes auth.registration_mode for
the frontend register-tab gating.
- New config keys: auth.registration_mode (self_serve | invite_only,
defaults to self_serve) and tenant.enable_rbac (defaults to false).
Both have WEKNORA_* env overrides.
- Frontend store persists memberships and exposes currentTenantRole;
Login.vue calls /auth/config and hides the Register tab in invite_only
mode. Go SDK types updated to match the new LoginResponse shape.
- Added support for tracking the status of FAQ import tasks, including progress indicators for ongoing imports.
- Introduced new localization strings for processing tasks and batch operations in multiple languages.
- Updated the FAQ entry manager to display import progress and improved user feedback during the import process.
- Enhanced the KnowledgeBase component to reflect the processing state of knowledge items, improving user experience during document uploads.
- Implemented batch deletion functionality for chunks, streamlining the management of knowledge base content.