Skip to content

feat: native organization multi-tenancy (tenant isolation) - #1

Merged
SteveKueng merged 7 commits into
beryllfrom
feature/org-tenant-isolation
Jul 16, 2026
Merged

feat: native organization multi-tenancy (tenant isolation)#1
SteveKueng merged 7 commits into
beryllfrom
feature/org-tenant-isolation

Conversation

@SteveKueng

Copy link
Copy Markdown

Native organization multi-tenancy (tenant isolation)

Activates and completes BetterDesk's existing but dormant org overlay so a
single instance can safely serve multiple isolated tenants. Users are scoped by
an OIDC org claim; devices by a per-org enrollment token; every
authenticated read path enforces the org boundary.

Fully backward compatible: every change no-ops when there is no org context
(empty claim / empty token org). Existing single-tenant deployments behave
exactly as before.

What each commit does

  • feat(oidc) — read a configurable org claim (claim_org, e.g. Zitadel
    urn:zitadel:iam:user:resourceowner:id), ensureOrg, mint an org-scoped JWT
    (GenerateOrgToken). Roles keep flowing through the existing group_role_map.
  • fix(users) — materialize claim-based membership into org_users on login;
    scope the user list to the org's members.
  • fix(cdap)requireDeviceOrgScope wrapper on all 9
    /api/cdap/devices/{id}/* routes; filter device list & alerts. (Closed the
    worst leak: live remote control of any tenant's device.)
  • fix(peers) — scope the RustDesk client peer-list path
    (?accessible/?pageSize) that bypassed the already-scoped admin path.
  • feat(enrollment)DeviceToken.org_id (dual-backend schema + migration);
    a token-gated register auto-assigns the device to the token's org. This
    populates org_devices, which every scope check filters against.
  • fix(audit,chat) — scope audit conn/file/alarm/summary by host-in-org; the
    global ops event feed is staff-only; scope chat contacts and gate
    history/send/unread (group & operator chats still work).

Config to enable (per instance, all existing keys except claim_org)

oidc.scopes = "openid profile email urn:zitadel:iam:user:resourceowner"
oidc.claim_org = "urn:zitadel:iam:user:resourceowner:id"

Mint one per-org enrollment token via POST /api/tokens {"org_id": "<org>"},
place it in the agent's config, run with ENROLLMENT_MODE=locked.

Verification

go build / go vet / go test green across db, api, auth. Added
TestJWTOrgTokenCarriesOrgID and TestDeviceTokenCarriesOrgID for the two
security-carrying data-path invariants.

Design notes: .claude/docs/uvit/features/BERYLL_TENANT_ISOLATION.md (workspace).

Activate the dormant org multi-tenancy for OIDC logins: read a configurable
org-id claim (claim_org, plus optional claim_org_name) from the token, ensure
the organization exists, and mint an org-scoped JWT via GenerateOrgToken so
downstream org-aware queries can scope by Claims.OrgID.
Provider-agnostic: claim_org is empty by default (login stays global/
server-level, fully backward compatible); set it per IdP, e.g. Zitadel
urn:zitadel:iam:user:resourceowner:id. Roles keep flowing through the existing
group_role_map. Orgs referenced by the claim are auto-provisioned on first
login (id = IdP org id).
This is phase A of native tenant isolation; query-path scoping (phase B) and
device-token org binding (phase C) follow.
handleListPeers already scoped the paginated path via ListPeersForOrg, but the
RustDesk-client envelope path (?accessible/?pageSize -> handleClientPeersList ->
buildRustDeskPeerList -> loadRustDeskPeerByID) called the global ListPeers,
leaking every tenant's devices to any org-scoped browser. Thread the caller's
org id into loadRustDeskPeerByID and use ListPeersForOrg when set; empty org id
keeps the global path (backward compatible). Both callers (peer list + groups)
pass getOrgIDFromCtx(r).
Phase B (query-path scoping), group 1/5.
CDAP device access was role-only: any org-scoped admin could open a live
desktop/terminal/video/audio/file session to ANY tenant's device, and the
device info/manifest/state routes had no auth wrapper at all. Add a
requireDeviceOrgScope router wrapper (reuses peerOrgScopeCheck) on all nine
/api/cdap/devices/{id}/* routes so an org-scoped caller is 403'd on a device
outside their org. Extract the underlying allow-logic into deviceInOrgScope
(no response written) and reuse it to filter handleCDAPListDevices and
handleCDAPAlerts. Global (no-org) users and super/global admins are unaffected.
Phase B (query-path scoping), group 2/5 — the highest-severity leak.
Two parts:
- On OIDC login with an org claim, link the server user into org_users
(ensureOrgMembership, idempotent + best-effort) so the claim-based membership
is reflected in the org member tables, not just the JWT. Login is never
blocked by a link failure.
- Scope handleListUsers and the RustDesk-client handleClientUsersList to the
caller's org members via orgUserScope (fails closed on lookup error). Global
and super/global-admin callers see all users unchanged.
Phase B (query-path scoping), group 3/5.
Bind an organization to an enrollment token (DeviceToken.org_id, dual-backend
schema + migration) so a per-org token placed in the agent's RustDesk config
lands the device in the right tenant atomically at enrollment — no separate
assign call, no race. On a token-gated register, assignEnrolledDeviceToOrg
upserts org_devices from the token's org. Empty org_id keeps today's global
behavior (backward compatible). Admins/uvit mint per-org tokens via
POST /api/tokens {org_id}.
This is the functional keystone: it is what populates org_devices, which the
Phase-B scoping filters against.
Phase C of native tenant isolation.
Audit: connection/file/alarm lists and the combined summary drop records whose
host device is outside the caller's org (orgFilterByHost on host_id); totals
reflect the filtered set for org-scoped callers. The global ops event feed
(/api/audit/events, /api/ws/events) mixes non-device server events, so it is
staff-only — org-scoped users get an empty feed / 403.
Chat: contacts list is scoped to the org's devices (ListPeersForOrg) and the
org's operators (orgUserScope); history, send and unread are gated by
chatTargetAllowed, which blocks only conversations/targets that are a device in
another org (group and operator ids pass, so those chats keep working).
Global and super/global-admin callers are unaffected throughout.
Phase B (query-path scoping), groups 4-5 of 5 — phase B complete.
@SteveKueng
SteveKueng merged commit e06b79f into beryllJul 16, 2026
1 of 2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@SteveKueng