Align product copy, API docs, Swagger descriptions, and i18n strings
with workspace terminology while keeping internal tenant identifiers
and headers unchanged for API compatibility.
Drop the standalone OIDC_AUTH_DEFAULT_TENANT_MODE / oidc_auth.default_tenant_mode
knob so OIDC auto-provisioning and local password registration share the single
auth.default_tenant_mode policy (resolved by the callback handler). Repair
tenantless sessions by falling back to the first active membership on the login
happy path and stale-preference paths, allow tenantless PUT /auth/me/preferences,
clear the tenant snapshot on /auth/me responses without a tenant, and default the
frontend canCreateTenant capability to fail-closed until /auth/me confirms it.
Allow deployments to register users without auto-creating a personal tenant, expose creation policy via /auth/me, and guide tenantless accounts through a workspace onboarding flow.
Reject refresh JWTs in ValidateToken, revoke all user sessions on logout,
and restrict create_knowledge_from_file to allowed directories on network MCP transports.
Apply the same tenant/integration secret policy to auth login, OIDC
callback, switch-tenant, and invite-register payloads, and return masked
config objects from Admin-only tenant KV PUT handlers.
Prevent low-privileged tenant members from reading raw API keys and
infrastructure credentials via tenant profile, KV config, model/MCP/web
search listings, and KB initialization config responses.
* feat(rbac): add multi-use share-link invitations for invite_only mode
When the auth.registration_mode toggle flips to invite_only, the public
/auth/register endpoint returns 403 — but until now there was no
channel for unregistered users to actually join a tenant. The existing
invitation table required a registered invitee, so new emails could
not enter the system at all.
This change introduces a share-link model:
- Owner generates a multi-use registration link with a per-tenant role
- Recipient opens the link, registers with their own email, and is
added to the tenant on the same request
- Token is stored in plaintext (not hashed) so the management UI can
re-display the URL on demand without a "copy now or revoke" trap.
Threat model is bounded by 7-day TTL, revocability, and the fact
that all the link grants is membership in one tenant.
- accepted_count tracks how many users joined through the link so the
Owner can tell whether a link is fresh or has already been spread
Per-user invitations (registered email -> in-app inbox accept) are
unchanged. Share-link rows use empty invitee_user_id as discriminator;
the partial unique index on (tenant_id, invitee_user_id) was relaxed
to skip empty values so multiple share links can coexist per tenant.
Frontend reuses Login.vue for /register?token=xxx instead of a
parallel page, and adds icon-only revoke / remove actions plus a
distinct "active" status tag for share-link rows.
Migration 000054 adds the token column, the accepted_count column,
and the relaxed pending unique index in a single step.
* feat(auth): update invitation token handling and add rate limiting
- Refactor the invitation token retrieval to use POST instead of GET, enhancing security by preventing the plaintext token from appearing in logs and browser history.
- Update the API endpoint from `/auth/invitations/:token` to `/auth/invitations/lookup` to reflect the new method.
- Introduce a rate limiter for unauthenticated share-link endpoints to mitigate brute-force attacks and abuse, ensuring a maximum of 30 requests per minute per IP.
- Adjust the registration flow to maintain user experience while securing the invitation process.
- Added WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL environment variable to promote a specified user to system admin on startup.
- Introduced a new bootstrap process in `bootstrap.go` to handle the promotion logic.
- Updated `.env.example` to document the new environment variable and its behavior.
- Created new views for managing system administrators and system settings, including listing, promoting, and revoking admin privileges.
- Enhanced the frontend to reflect the new system admin features, including UI elements for admin management and settings configuration.
- Updated API interfaces to support system admin functionalities, ensuring proper data handling and user management.
DISABLE_REGISTRATION=true used to block /auth/register at the handler
layer only, leaving /auth/config still reporting self_serve. The
frontend therefore kept showing the Register entry even when the env
var was set — clicking it just hit the 403. Two gates, out of sync.
Wire DISABLE_REGISTRATION=true through applyAuthAndTenantDefaults so
it coerces auth.registration_mode to invite_only (env wins over YAML,
matching docs/rbac.md). The handler-side os.Getenv check is now
redundant with IsInviteOnly() and is removed, leaving a single
enforcement path.
Add config tests pinning down the env/YAML matrix, including the
explicit-self_serve override case that would otherwise be the easy
regression to ship.
Until now a fresh login (new device, expired refresh token, cleared
browser) always dropped the user into their home tenant, even if they
spend most of their time in a peer workspace. The session-local
X-Tenant-ID override in localStorage gave a "same browser sticky"
effect, but never crossed devices or new sessions.
This adds a small server-side preference, `users.preferences.
last_active_tenant_id`, persisted in the existing jsonb column (no
new migration), and threads it through:
* Backend
* `UserPreferences` gains `LastActiveTenantID *uint64` with sentinel
semantics (`*0` from the PATCH endpoint = clear preference).
* `resolveLoginTenantID` validates the stored id (tenant still
exists + active membership, or CanAccessAllTenants) and falls
back to home on any failure, best-effort clearing the stale
preference so subsequent logins don't pay for it again.
* `Login` and `LoginWithOIDC` resolve once and use the result for
both the JWT `tenant_id` claim and the returned `active_tenant`,
keeping the two in sync. `RefreshToken` rides through
`GenerateTokens` so refresh rotations also land the user back in
their preferred tenant instead of bouncing to home.
* `UpdateUserPreferences` learns to merge the new key.
* `PUT /auth/me/preferences` accepts the new field.
* Frontend
* `Login.vue` now writes the user's HOME tenant id into
`user.tenant_id` (matching the field's documented semantics) and
expresses any active-vs-home divergence via `setSelectedTenant`,
so `useHomeTenant` and the "current"/"home" badges stay correct
after the backend honours a remembered preference. `App.vue`'s
OIDC sync does the same reconciliation.
* `TenantSelector`, `UserMenu` and the post-tenant-create handlers
fire `persistLastActiveTenantPreference` after every successful
user-initiated switch (switching to home sends `0` to clear).
The call is raced against the existing reload-grace window so
most writes finish before the page tears down; lost writes are
recoverable on the next switch.
No new UI. Users will simply notice that, after re-logging in on
another device, they land back in the workspace they were last
using rather than always in their home tenant.
Note: `make docs` is unrelated-broken on `main` (audit_log.go
references `errors.AppError` which swag can't resolve), so the
Swagger artifacts under docs/ are intentionally not regenerated
in this PR. The handler code is the source of truth.
Added functionality for managing user preferences, including the ability to update preferences via a new API endpoint. The preferences are now stored server-side, allowing for synchronization across devices. Updated relevant components to handle user preferences, including the UserMenu and settings views. Enhanced internationalization support for error messages related to preference updates.
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.