Repository navigation
Admin Panel: Users + Workspaces + Audit log (V27 → V30) - #10883
MichaelUray wants to merge 183 commits into
Conversation
…t log Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…n + lastActivityAt Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Adds AdminAuditAction enum, AdminAuditLogEntry DTO and AdminAuditLogCollection interface to types.ts. Wires PostgresAdminAuditLogCollection (insert, findByTarget, findByAdmin) into PostgresAccountDB so admin actions can be recorded against the V27 admin_audit_log table. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Adds two helpers in utils.ts that wrap @hcengineering/server-token: - generateTokenWithVersion attaches the account's tokenVersion as a token_version extra-claim when > 0 (skipped for GUEST and non-UUID principals). - verifyTokenVersion rejects tokens whose claim is stale relative to the account row, and rejects disabled accounts (disabledAt != null). Unit tests cover claim-presence, monotonic invalidation, and disabled rejection (5 cases, all green). Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Migrates 19 generateToken call-sites in utils.ts + operations.ts to the new helper so account.tokenVersion is honored for every issued JWT. Exceptions kept as direct generateToken: 3 GUEST_ACCOUNT paths (login as guest, share-link access, access-link grant). sendEmailConfirmation now takes AccountDB so the confirmation token also carries the version claim; tests + callers updated. MongoAccountDB gets a Mongo-backed AdminAuditLogCollection stub (lazy collection getter) so both backends implement the AccountDB interface. All 479 unit tests pass. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…nInfoByToken Both DB-aware verification entry points now call verifyTokenVersion right after decodeTokenVerbose. This is where the account-disable / token-bump hard-cut takes effect for active sessions on next request. verifyTokenVersion also gains explicit short-circuits for systemAccountUuid and readOnlyGuestAccountUuid (service principals with no DB row), and the missing-account case is now treated as a service token (no rejection) so NIL_UUID-style 2FA-pending tokens keep working. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… flows Wires touchLastActivity into: - login (after password verification + reset-failed-attempts) - validateOtp (after successful OTP verification) - selectWorkspace (after auth checks, skipped for system / read-only-guest) - loginOrSignUpWithProvider (after confirmHulyIds finalizes the OIDC account) System and read-only-guest accounts are excluded from activity tracking. All 482 unit tests still pass. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…erver/account Introduces ListAccountsAdminParams, AccountListRow and AccountDetailsResponse in @hcengineering/account-client so the upcoming admin endpoints in server/account share a single source of truth with the frontend. server/account gains a workspace-dep on account-client; pnpm-lock.yaml refreshed via rush update. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…aginate)
Adds the admin-only listing endpoint used by the new user-management UI.
Filters: search (name/email substring), status (active/disabled), auth-method
(email_only/oidc/mixed), workspace-overlap. Sorts: name / last_activity /
workspace_count. Pagination via {limit, offset}.
Registered as a service method; rejects non-admin callers with Forbidden.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ships + recent audit) Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Admin-only role mutation for a workspace member. Rejects: - non-admin callers (Forbidden) - target not a member of the workspace (AccountNotFound) - demoting the last Owner of a workspace (last_owner_in_workspace) On success: updates the role via AccountDB and records a role_change entry in the admin_audit_log. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… guard)
Idempotent: returns { ok: true, wasMember: false } when target is not a
member of the workspace. Same last-Owner guard as setWorkspaceMemberRole.
On success records a remove_member audit entry with the prior role.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…only — Option B) Admin-only password-reset trigger. Strict semantics per spec: - Rejects with user_has_no_email when target has no email identity - Rejects with user_has_no_password when target is OIDC-only (no hash) - Reuses existing requestPasswordReset flow for email send - Email-send failures are logged (audit entry still recorded) Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…sage Extends WorkspaceEvent with AccountDisabled so the broadcast TxWorkspaceEvent can carry the force-logout signal through the existing Tx-dispatch path. QueueAccountLifecycleMessage is the cross-pod payload published to the account.lifecycle topic by the account pod when an admin disables/enables an account; consumed by TSessionManager in each worker pod. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…sion bump) Admin-only hard-disable that: - Rejects self-disable (cannot_self_disable) - Rejects disabling the only configured admin (last_admin) - Atomically sets disabledAt + bumps tokenVersion (synchronous fallback) - Emits QueueAccountLifecycleMessage on account.lifecycle (force-logout path) - Records a disable audit entry Queue producer is injected via the new AccountMethodDeps option to getMethods(). Producer failures are logged but the synchronous token-version bump still locks the user out on the next request. New wrapWithDeps helper mirrors wrap() but injects deps as the 4th arg. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ersion) Admin-only re-enable. Clears disabledAt and bumps tokenVersion so any remaining stale tokens with the old version still get rejected by verifyTokenVersion until the user re-authenticates. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…r init serveAccount now accepts AccountMethodDeps (4th arg). The account-pod __start.ts conditionally creates an 'account.lifecycle' producer via @hcengineering/kafka when QUEUE_CONFIG is set, and injects it into the deps object. If QUEUE_CONFIG is missing or queue init throws, the producer is left undefined and disableAccount silently falls back to the synchronous token-version bump path. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ountDisabled Subscribes to the account.lifecycle topic. On a 'disabled' message: 1. Finds all sessions for that account in this pod's session table 2. Broadcasts a TxWorkspaceEvent.AccountDisabled to each matching session via the existing Tx-dispatch path (client-resources will treat this as a force-logout signal in the next task) 3. Closes the WebSocket so the client does not reconnect with the same stale token Cross-pod fan-out works because every transactor pod subscribes to the same topic with its own consumer-group id (generateId()). Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…orce-logout store client-resources: - Adds forcedLogoutReason private field on Connection. When the Tx-dispatch receives a TxWorkspaceEvent.AccountDisabled, the field is set and the module-private forceLogoutHandler is invoked. - wsocket.onclose now skips scheduleOpen when forcedLogoutReason is set, preventing the client from reconnecting with the same stale token. - Exports setForceLogoutHandler so consumers can register a callback without taking a circular dep. login-resources: - Adds the forceLogoutReason svelte writable store in utils.ts. - index.ts wires the client-resources handler to the store at module load so any caller that imports login-resources gets the bridge for free. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Adds the modal that fires when the force-logout store is set. LoginApp.svelte subscribes to forceLogoutReason; when non-null the modal overlays everything (z-index 10000) and the only action is 'Sign out' which sends the user back to /login. By then client-resources has already cleared the local session token, so the redirect lands on the login page rather than re-entering with a stale token. New i18n strings AccountDisabledTitle / AccountDisabledBody / SignOut in en + de. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…eholder LoginApp.svelte subscribes to the location store and reads path[2] under the 'admin' page to pick between AdminWorkspaces (legacy default) and the new AdminUsers component. The placeholder redirects non-admin users back to /login and shows a stub — Tasks 23-25 fill the page with the list/drawer UI. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… endpoints Adds 7 new methods to the AccountClient interface and implementation: listAccountsAdmin, getAccountDetails, setWorkspaceMemberRole, removeWorkspaceMember, triggerPasswordReset, disableAccount, enableAccount. All thin RPC wrappers around the corresponding server endpoints added in Tasks 10-17. Frontend can now call them via getClient(...). Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…+ pagination Full UI for the /login/admin/users route: - AdminUsers.svelte: top-level state holder, calls listAccountsAdmin RPC - AdminUsersFilterBar.svelte: search box + auth-method + status dropdowns (300ms debounce on search) - AdminUsersTable.svelte: sortable table (name / workspace-count / last-activity) with row-click handler - AdminUsersRow.svelte: row layout with status/admin badges and a relative-time formatter for last activity - AdminUsersPagination.svelte: prev/next + page indicator - AdminUsersDrawer.svelte: stub for Task 25 Filter / sort / pagination changes all re-call the API. Drawer opens on row click and re-fetches when account-changed event fires. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Replaces the Task 24 stub with the real detail view: - Loads AccountDetailsResponse via getAccountDetails RPC on mount - Renders identities (email + OIDC) with verification badges - Workspace memberships list with inline role selector and remove button - Last-activity timestamp - Admin action buttons: trigger password reset, disable/enable All error codes from the backend (last_owner_in_workspace, cannot_self_disable, last_admin, user_has_no_email, user_has_no_password) are caught and surfaced to the admin. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…og + lifecycle event Exercises the full admin lifecycle end-to-end against a mocked AccountDB that mirrors the real persistence surface: - disable then enable bumps tokenVersion twice and clears disabledAt - audit-log accumulates role_change + disable + enable entries - lifecycle event is sent when a producer is provided The real-Postgres variant remains a deferred follow-up against postgres-real.test.ts. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Marked as .skip until the dk3 staging deploy is reachable from the test runner. Documents the planned scenarios so they can be activated as a follow-up commit. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… exprs The inline 'e.currentTarget.value as unknown as AccountRole' inside the select on:change attribute failed the webpack/svelte parser used by the front bundle (svelte-check tolerated it; webpack didn't). Extract a parseRole helper in the script block so the attribute expression is a plain function call. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Backend security blockers: * login / validateOtp / loginOrSignUpWithProvider now reject when the target account.disabledAt is set. Without this, generateTokenWithVersion would happily mint a fresh token for a disabled account; verifyTokenVersion only blocked it later in selectWorkspace/getLoginInfoByToken. * All admin endpoints now go through a requireAdmin() helper that calls verifyTokenVersion BEFORE the admin-flag check, so a stale token from a freshly-disabled admin cannot be used to disable/role-change anyone else. Applies to setWorkspaceMemberRole, removeWorkspaceMember, triggerPasswordReset, disableAccount, enableAccount, listAccountsAdmin, getAccountDetails. UI correctness: * listAccountsAdmin and getAccountDetails now resolve firstName/lastName from the person table (the real source of truth) instead of reading non-existent fields on account. List rows + drawer details now show real names; search by name works. * Force-logout bridge in login-resources/index.ts also clears presentation.metadata.Token and login.metadata.LoginEndpoint so the next page nav can't re-enter with a stale token. Logic: * Last-admin guard now walks ADMIN_EMAILS, resolves each entry to an account via its email social id, and counts only currently-active admins. Two-admin configs with one missing/disabled now correctly refuse to disable the last active admin. * triggerPasswordReset re-throws on email-send failure (was silently returning ok:true) and writes a 'failed: true' audit entry. * enableAccount is now idempotent for never-disabled accounts: skips the tokenVersion bump (per spec §10.7) but still records an audit entry with details.noop=true. Tests: * Mock DBs in listAccountsAdmin/getAccountDetails tests gain a person stub (the new join). * triggerPasswordReset happy-path test now asserts the new password_reset_send_failed bubble-up + audit-on-failure. * New forceLogout.test.ts in client-resources covers the public setForceLogoutHandler contract (deep simulation deferred to E2E). Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… Tx-dispatch tests
Two Codex follow-up findings:
1. Force-logout bridge now clears login.metadata.LoginAccount in addition
to LoginEndpoint. The active-account marker is what LoginApp checks on
bootstrap to decide whether to auto-resume — without clearing it, a
disabled user's next page-load would still try to come back in.
2. forceLogout.test.ts was too shallow (just verified the setter exists).
Replaced with three integration cases inside connection.test.ts that
exercise the real wire path:
- AccountDisabled Tx on the socket invokes the registered handler
with the configured reason
- onclose after force-logout does NOT call socketFactory again
(skip-reconnect contract)
- missing params.reason defaults to 'account_disabled'
Uses the existing MockWebSocket harness, keeping the extraction-to-test-utils
work out of scope for this fix.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…uced scope)
V13 instruments three call sites because the code flow is more complex
than the v2 plan assumed:
1. disableAccountInternal (operations.ts) - already had the
cannot_self_disable + last_admin checks. v13 adds an audit-write
before each throw. Method tag: 'disableAccount'.
2. disableAccount (operations.ts) was a SEPARATE duplicate of
disableAccountInternal's logic, not a thin wrapper. v3 refactors
it to delegate: const adminUuid = await requireAdmin(...); return
await disableAccountInternal(...). This eliminates ~50 lines of
duplication and routes the audit-write from (1) through to the
single-RPC path automatically.
3. bulkSetDisabled (serviceOperations.ts) calls bulkLoop with a
selfFilter. bulkLoop short-circuits self-targets BEFORE op() runs,
so disableAccountInternal never sees the bulk-self case - meaning
the audit-write from (1) doesn't fire for that path. v3 adds an
explicit pre-bulkLoop audit-write in bulkSetDisabled. Method tag:
'bulkSetDisabled'.
Two deny reasons emit an audit row before throwing. The methodName
parameter on disableAccountInternal carries the caller identity so
both reasons get the correct method tag:
Single RPC -> method='disableAccount' (default)
Bulk RPC -> method='bulkSetDisabled' (passed explicitly by
bulkSetDisabled per-target,
and by the pre-bulkLoop
self-write in bulkSetDisabled
itself)
Reasons -> 'self_disable' (UI/RPC-caller is target) or
'last_admin' (target is sole remaining admin)
Pre-auth Forbidden (no admin claim) writes NO audit row - attack-
surface concern per spec.
Rate-limit key: (actor_uuid, reason, method) per 1-hour window,
in-memory Map. Resets on pod restart (acceptable threat-model trade-
off). The method component means single-RPC and bulk attempts produce
separate rows when both happen, giving operators visibility into both
attack surfaces.
Reduced scope vs. v5 plan: the self_delete reason is gone with the
deleteAccount removal in PR hcengineering#10883. Hard-Delete v2 (future PR) will
re-introduce the self_delete + workspace-orphan deny audits.
Audit filter dropdown in the UI gains the new action type.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Per Igor's PR comment, hard-delete should not ship in this PR. The scope reduction removes the full code path, not just the UI surface: - DeleteAccountConfirm.svelte component file - Danger Zone section in AdminUsersDrawer (with the Delete button + Self-block markup specific to it; Disable's Self-block stays) - deleteAccount RPC function in operations.ts (+ cannot_self_delete guard, V19/V24 cascade calls scoped to it, audit-row write, AccountEventType.ACCOUNT_DELETED emit) - 'deleteAccount' from the AccountMethods union (operations.ts) - deleteAccount: wrap(deleteAccount) from getMethods (operations.ts) - deleteAccount method from AccountDB interface (types.ts) - 'delete_account' from AdminAuditAction (types.ts) - Postgres and Mongo implementations of deleteAccount - deleteAccount client binding in foundations/core/packages/account-client/src/client.ts - 'delete_account' entry from AdminAudit ACTION_OPTIONS dropdown - deleteAccount.test.ts test file The ACCOUNT_DELETED entry in AccountEventType stays — Task 1 discovery confirmed it pre-existed PR hcengineering#10883 (introduced by upstream UBERF-11998 in PR hcengineering#9441, reachable from develop), so it isn't ours to remove. Hard-delete, if it comes back, will be a separate smaller PR with a content-reference audit, sole-owner workspace guard, and impact preview. Disable remains as the only account-state action in this PR. Spec: docs/superpowers/specs/2026-05-27-huly-admin-account-lifecycle-design.md Signed-off-by: Michael Uray <michael.uray@gmail.com>
…uced scope)
V13 instruments three call sites because the code flow is more complex
than the v2 plan assumed:
1. disableAccountInternal (operations.ts) - already had the
cannot_self_disable + last_admin checks. v13 adds an audit-write
before each throw. Method tag: 'disableAccount'.
2. disableAccount (operations.ts) was a SEPARATE duplicate of
disableAccountInternal's logic, not a thin wrapper. v3 refactors
it to delegate: const adminUuid = await requireAdmin(...); return
await disableAccountInternal(...). This eliminates ~50 lines of
duplication and routes the audit-write from (1) through to the
single-RPC path automatically.
3. bulkSetDisabled (serviceOperations.ts) calls bulkLoop with a
selfFilter. bulkLoop short-circuits self-targets BEFORE op() runs,
so disableAccountInternal never sees the bulk-self case - meaning
the audit-write from (1) doesn't fire for that path. v3 adds an
explicit pre-bulkLoop audit-write in bulkSetDisabled. Method tag:
'bulkSetDisabled'.
Two deny reasons emit an audit row before throwing. The methodName
parameter on disableAccountInternal carries the caller identity so
both reasons get the correct method tag:
Single RPC -> method='disableAccount' (default)
Bulk RPC -> method='bulkSetDisabled' (passed explicitly by
bulkSetDisabled per-target,
and by the pre-bulkLoop
self-write in bulkSetDisabled
itself)
Reasons -> 'self_disable' (UI/RPC-caller is target) or
'last_admin' (target is sole remaining admin)
Pre-auth Forbidden (no admin claim) writes NO audit row - attack-
surface concern per spec.
Rate-limit key: (actor_uuid, reason, method) per 1-hour window,
in-memory Map. Resets on pod restart (acceptable threat-model trade-
off). The method component means single-RPC and bulk attempts produce
separate rows when both happen, giving operators visibility into both
attack surfaces.
Reduced scope vs. v5 plan: the self_delete reason is gone with the
deleteAccount removal in PR hcengineering#10883. Hard-Delete v2 (future PR) will
re-introduce the self_delete + workspace-orphan deny audits.
Audit filter dropdown in the UI gains the new action type.
Signed-off-by: Michael Uray <michael.uray@gmail.com>
86302bf to
1d61952
Compare
|
This is a fair concern. I re-checked the delete path and agree it should not ship in this PR. I'm removing the hard-delete UI and RPC from #10883. Disable remains as the only account-state action in this PR. It's reversible — it preserves the account / person / social-id anchors, blocks login via the If hard-delete comes back, I'll do it as a separate, smaller PR with an explicit content/reference audit, sole-owner workspace guard, and an impact preview before confirmation. I also want to separate that from a privacy-oriented anonymization path, since deleting authored project content is not the same problem as removing personal identifiers. The structural removal commit is Four additional commits layered on top while this branch was up for review (V5a workspace-root link in the drawer, V7 audit Custom-range pre-fill, V8 audit timestamp timezone tooltip, V13 Thanks for catching this before it shipped as default-on — the answer to "is hard-delete safe?" was "we haven't proven it yet," and shipping with that assumption would have been wrong. |
|
Cool, yeah it's good to not include it. I'd also suggest to update PR body to remove that block about hard delete. Edit: I see removed already 👍 |
|
Hey @MichaelUray, curious whether you continued this work or have follow-up branches around workspace-level RBAC / access management. From looking through current Huly settings, this PR seems to solve the global instance-admin surface well: users across workspaces, workspace membership, audit, disable/enable, bulk operations, etc. The gap I am seeing is the in-workspace access model: workspace account roles ( Have you explored a centralized Workspace Access Center that sits inside a workspace and lets an owner see/edit access per person and per resource? Potential MVP:
I would avoid making Would be interested if you already have work in this direction, or thoughts on whether this PR's admin table/drawer/audit patterns could be reused for an in-workspace access center. |
|
Hi @ignatremizov — quick follow-up to your Workspace Access Center question. Thank you for the suggestion — it was a clear framing of where workspace administration could use a centralized surface, and gave me a concrete direction to work toward. I implemented a v1 of that direction in my fork: a per-workspace settings panel with People, Resources, My Access, Audit, and Guest Settings tabs, plus common access-management actions and audit visibility for workspace Owners. It is not an upstream PR yet because the account-service host wiring builds on helpers introduced in this Admin Panel PR ( Until then, the user-facing surface and design are captured in:
The v1.5 preview features (Permission Templates, Webhooks, Time-bounded Grants, CSV Bulk-Invite, Effective Permissions Drilldown) are intentionally out of scope for the first upstream PR. Any comments or suggestions on the direction, scope, or specific design decisions would be welcome at this stage — much easier to adjust now than after the upstream PR opens. Sharing as context for the admin/access-management direction; no action needed unless useful. |
…management Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…-Scan (L-FTS)
Full-Table-Scans (db.account/person/socialId.find({})) luden komplette
Tabellen und filterten in-memory -> Speicher-/DoS-Risiko. Jetzt gezielte
$in-Query auf die Member-UUIDs; leere Member-Menge ueberspringt die Query.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
- requireAdmin/assertAdmin loggen pre-auth Denials (ctx.warn) — bislang spurlos, da kein Audit-Row fuer nicht-vertrauenswuerdige Actor. - Verschluckte Audit-Write-Failures emittieren stabilen Alert-Marker AUDIT_WRITE_FAILED (auditAdminActionDenied + performWorkspaceOperation); Mutation-Verhalten unveraendert. - Rate-Limit-Kommentar praezisiert: Security-Log ist forensische Quelle. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Guard-Zaehlung + Write waren nicht atomar -> parallele Calls konnten den letzten Owner/Admin gleichzeitig entfernen. - Neue DB-Methoden updateWorkspaceRoleIfOtherOwnerExists / unassignIfNotLastOwner: Postgres sperrt die Owner-Zeilen (SELECT .. FOR UPDATE) in einer withRetry-Transaktion und prueft+schreibt atomar; auf CockroachDB serialisiert der 40001-Retry. Mongo best-effort (deprecated, kein Cross-Doc-Lock — dokumentiert). - setWorkspaceMemberRole/removeWorkspaceMember(Internal) nutzen die bedingten Updates; 0 betroffene Zeilen -> last_owner_in_workspace. - disableAccountInternal: Re-Check der Last-Admin-Invariante NACH dem Write mit Rollback (re-enable), fail-closed. tokenVersion bleibt bewusst gebumpt (nie zuruecksetzen). Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…t in webpack dev-proxy note Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
|
@ArtyomSavchenko — following up on the question of direction here. This PR has grown large and now conflicts with the recent revocable-API-tokens work in
For the new disable flow, it would also ensure that the disabled account's API tokens are invalidated. Would that foundation be useful upstream, and if so, what shape would you prefer? Or would you rather design instance-admin/account tooling separately? I'd prefer to align before investing in the extraction. |
|
@ArtyomSavchenko — following up on the question above (2026-08-09). The branch now conflicts with current If a backend-only foundation PR is a shape you'd review — account disable/enable with a token-version bump, the admin audit log with its migration, and the |
Hi @MichaelUray |
…sendInvite, resendInvite, changePassword These four operations mint or extend privileges from the caller's token (guest access links, invites, password rotation) but never checked the account's token_version or disabled_at. A session invalidated by a token_version bump (or a disabled account) could therefore still create access links / invites or change the password. - utils.ts: split verifyTokenVersion into a pure checker (checkTokenVersionClaim) and the existing DB wrapper; the wrapper keeps its exact semantics for getLoginInfoByToken / selectWorkspace / requireAdmin. - utils.ts: new verifySessionTokenVersion(accountUuid, extra, account), called once in each of the four operations with the account row the operation already loads (no second account query). - wrap() is NOT changed: no central token-version gate, so UUID-backed service tokens without a token_version claim keep working for all other RPCs. - Tests: tokenVersion.test.ts (pure checker + API-token exemption) and new tokenVersionOps.test.ts covering stale / disabled / fresh / API / legacy tokens per operation, directly and through getMethods()/wrap (the bearer RPC dispatch path), plus a getUserWorkspaces pass-through test for versionless service and API tokens (guards against a gate in wrap()). API tokens (extra.apiTokenId) are exempt from this check: they carry no token_version claim and keep their existing revocation/expiry check in wrap(). API-token semantics are unchanged. Whether API tokens should be allowed to mint access links / invites at all is tracked as a follow-up (E-T3). Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ole-writing path The admin RPCs wrote the requested role straight to the DB. Values that are not workspace-member roles (Admin, DocGuest, ReadOnlyGuest, or any non-enum string such as the NaN produced by the old UI parseRole) either caused a DB error (500), were persisted as a member role (READONLYGUEST), or - in createAccountAdmin - silently left a freshly created account without its initial workspace. New helper assertAssignableRole (utils.ts) throws BadRequest unless the role is one of assignableRoles (Guest, User, Maintainer, Owner). It is called before any DB access in: - setWorkspaceMemberRole (right after requireAdmin; no audit row) - addWorkspaceMember (right after assertAdmin) - bulkAddToWorkspace (once per batch, before bulkLoop; no batch, no audit rows) - addWorkspaceMemberInternal (defense in depth for future callers) - createAccountAdmin (for initialWorkspace.role, before the account is created) Internal writers of non-assignable roles (ReadOnlyGuest pseudo-account when enabling read-only guests, access-link grants, invites) are not admin paths and are unchanged; invites are already validated against assignableRoles at creation. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ethodIn 'none'
Two mismatches between the public account-client types and the
server-side SQL pushdown (both reported by `rush validate`):
- AccountListRow.hasPassword was declared but never populated. The rows
query now selects
EXISTS (SELECT 1 FROM <ns>.account_passwords pw
WHERE pw.account_uuid = a.uuid) AS has_password
and rowToAccountListRow maps it to hasPassword.
- ListAccountsAdminParams.authMethodIn allows 'none', but the internal
ListAccountsAdminQueryParams type and the SQL builder did not, so the
"Auth -> none" filter was silently a no-op. The builder now emits
(NOT COALESCE(s.has_email, false) AND NOT COALESCE(s.has_oidc, false))
for 'none' (COALESCE because accounts without any social_id row get
NULLs from the LEFT JOIN), OR-combined with the other methods.
New listAccountsAdminPg.test.ts covers buildListAccountsAdminSql and
rowToAccountListRow directly (listAccountsAdmin.test.ts only exercises
the RPC with an in-memory DB).
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…oles (Number() yielded NaN)
AccountRole is a string enum ('GUEST', 'USER', 'MAINTAINER', 'OWNER', ...),
but parseRole returned Number(v) as AccountRole, i.e. NaN for every role
(and a TS2352 error under `rush validate`). Every admin role change /
add-to-workspace sent NaN to the server.
- New admin-users/roles.ts (depends only on @hcengineering/core):
ASSIGNABLE_ROLES mirrors the server's assignableRoles; parseRole returns
the value unchanged when it is one of them and throws otherwise
(including Admin, DocGuest and ReadOnlyGuest, which are not member
roles), so a mismatch surfaces in the UI instead of reaching the API.
- util.ts re-exports parseRole from roles.ts.
- AdminUsersDrawer.svelte used parseRole without importing it (the import
was lost when confirmAction/notify were consolidated into util.ts), so
changing a member role from the user drawer threw a ReferenceError.
Import it.
- AdminWorkspaceDrawer.svelte passed the raw dropdown value to
onChangeRole; it now goes through parseRole like the other callers.
- New __tests__/parseRole.test.ts: the four roles round-trip; every
non-assignable enum role and '4', 4, 'NaN', undefined, null, '',
'user', 'DOCGUEST' (DB label, not an enum value) and {} throw.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
|
Thanks @ArtyomSavchenko. The backend-only foundation is now up as #11055, as a fresh port against current In #11055:
Not in #11055:
I'll keep this PR open as the stacked follow-up for the admin panel UI (and the workspace-membership part) and move it to draft. Once the foundation is merged I'll rebase it, and the diff will shrink to those parts. |
|
@ignatremizov following up on this: I opened a short design question for the in-workspace access overview in #11057, scoped down to an Owner-only, read-only overview of space membership and configuration over the existing |
…opy email The workspace drawer called setWorkspaceMemberRole and removeWorkspaceMember with a params object although both client methods take positional arguments, so the server received an undefined role (BadRequest) and an undefined workspace. Copy email read a primaryEmail field the details response does not have; derive it from socialIds. Also use the ButtonIcon tooltip prop and IntlString placeholders. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Admin Panel: Users + Workspaces + Audit log (V27 → V30)
This PR introduces a full admin panel under
/login/adminwith threesections — Users, Workspaces, and Audit log — backed by four DB
migrations (V27 → V30) and a suite of new admin-only RPC methods on
@hcengineering/account. Self-hosted Huly operators can now manageaccounts and workspaces, audit administrative actions, and bulk-disable
or archive at scale, all without dropping to SQL.
Companion docs: hcengineering/huly-docs#71 — adds a top-level Admin panel section to the docs site with Overview / Users / Workspaces / Audit log / Configuration pages and screenshots.
The problem this solves
(Added in response to @ignatremizov's first-round review.)
Self-hosted Huly operators have to drop to raw SQL for everything
cross-workspace today: disabling a compromised account, force-logging
out a user after a security event, finding orphan accounts (no
workspace memberships), checking who has admin access on which
workspace, bulk-archiving stale workspaces, or auditing what an admin
did last quarter. There is a workspace-scoped
Settings → Memberssurface, but no global-account surface — and once you have more than
~5 workspaces, the per-workspace Settings pane stops scaling.
What already existed (so this is exposure, not reinvention). The
account service already shipped the primitives —
assignWorkspace,unassignWorkspace, social-id management,setWorkspaceMemberRole,the
ADMIN_EMAILSenv gate. They just had no UI and were not exposedvia
account-client. This PR wires UI + bulk endpoints + audit on topof what the account service already enforces.
Why a separate route instead of expanding an existing one. Two
existing surfaces were in scope to extend rather than add:
Settings → Members— workspace-scoped, member-of-workspace view. Can't cross workspace; can't disable an account
globally; can't show which workspaces a person is in. Wrong domain
to bolt cross-workspace controls onto.
system-managerplugin — runs inside the per-workspacetransactor session pipeline and doesn't talk to the account DB
directly. The admin panel uses the account-service RPC instead,
which is the source of truth for accounts and workspaces.
We chose
/login/adminbecause of the session boundary (the panel mustwork even when no workspace is open). If you'd rather see this folded
under
system-manageronce the dust settles, the routes can be moved.Multi-tenancy. Huly already supports one account → many workspaces.
ADMIN_EMAILSwas the existing RBAC primitive; before this PR, "beingadmin" had no UI meaning — it only gated server-side mass operations
callable from internal scripts. This PR is the first surfaced consumer
of that primitive.
Iteration since the first review
Five commits were added on top of the original 159-commit history in
response to your feedback. Each one is small and addresses one specific
concern:
0c3af29d1cBulkActionBarfrom sticky-bottom to sticky-top inAdminUsers.svelte; the bar now appears between the filter row and the table, hidden when no rows are selected.384a81d979AdminAudit.sveltewas callinggetAccountClient(null).listAuditAdmin()— explicitly passing no token. Server-sideassertAdminconsequently always returned Forbidden. Fix: use the default token (getAccountClient()) so the existing admin claim travels with the call, like every other admin-panel RPC.ab2589361eto-date parsed as UTC midnight 00:00:00.000, which excluded every event logged "today" — the default "Last 3 days" preset hid the freshly-created disable / delete entries until the admin manually picked a Custom range. Anchortoto end-of-day (23:59:59.999) inside the listAuditAdmin params builder. (b) Drawer offered the Disable button on the admin's own row; the server guardcannot_self_disablecaught it, but only after the admin had clicked through. SurfaceisSelfclient-side by decoding the JWT payload locally — no extra RPC — and grey out the button with an explanatory label. The server guard still enforces as the security primitive; this is purely a UX preview of the impossible action.086b7f9530DeleteAccountConfirm.sveltedeleted;deleteAccountfunction, postgres impl, mongo impl,AccountDB.deleteAccountinterface method,'deleteAccount'fromAccountMethodsunion,'delete_account'fromAdminAuditAction, the client-binding in account-client, and thedelete_accountentry in the audit-action dropdown all removed.deleteAccount.test.tsdeleted. Static grep verifies zero stragglers. Disable remains as the only account-state action in this PR. If hard-delete returns, it will be a separate smaller PR with a full content/reference audit, a sole-owner workspace guard, and an impact preview before confirmation.08472f43e8Open ↗link to/workbench/<wsUrl>in a new tab. Workspace-root link only; an Employee deep-link via per-workspaceRef<Employee>lookup is deferred to a future enrichment.2869af32bacb8c4a2409titletooltip on hover —Local time: ... (<TZ>, UTC<offset>)andUTC: .... ExtractedtzTooltiphelper into its own module with 5 unit tests covering the Intl-unavailable degraded path.1d61952b93cannot_self_disableandlast_admin(for Disable) now write anadmin_action_deniedaudit row before throwing. Rate-limited per(actor, reason, method)in a 1-hour window. Pre-auth Forbidden writes no row. Instrumentation lives indisableAccountInternal(Step 5a),disableAccountis refactored to delegate to it (Step 5b, eliminates 50 lines of duplicate body), andbulkSetDisabledadds a pre-bulkLoop self-audit becausebulkLoopshort-circuits self-targets before the per-target call runs (Step 5c). 7 unit tests cover the decision matrix. Audit-action dropdown gainsadmin_action_denied.Identities / Employee / Contact mapping
You asked about
Employee/ canonical contacts / HR integration. Wherethis PR sits today:
account.uuidplus itsemail-typed
social_id. Person / Employee / Contact / HR recordsare workspace-internal and untouched.
Employeebridge: lives in the workspace'stransactor DB, not the account DB. The admin panel is a global-
account view and does not enumerate Employee rows.
accounts (each has a global account row + email social-id). The
drawer's role picker and the table's role chip reflect what's in
global_account.workspace_members.rolefor that workspace.additional
github-typed social-id. The drawer surfaces this in theIdentities section but does not link out to the IdP — deferred.
If the value would be there, the drawer can be extended in a follow-up
to add a "in workspace X, you're Employee Y" back-link. Not in this PR.
Per-phase commit ranges
394e3db143…efe42d1617(~110 commits)listAccountsAdmin,getAccountDetails,disableAccount/enableAccount,setWorkspaceMemberRole,removeWorkspaceMember,triggerPasswordReset,addToWorkspace,bulkSetDisabled,bulkSendPasswordReset,bulkAddToWorkspace,bulkRemoveFromWorkspace. Force-logout hook (AccountDisabledTx → client-side store). Admin-only RBAC viaassertAdmin. UI: AdminShell, AdminUsers + drawer, AdminWorkspaces + drawer, MassActionConfirm, FilterPresetMenu, ColumnFilterPopup. V28 migration to relaxadmin_audit_log.target_accountNOT NULL + 5 query-tuning indexes.c5da4d170c…d80a9710d0(~10 commits)decodeFilterParamextract with strict base64 + recursive prototype-pollution rejection + 32-level depth cap (15 tests). SharedcsvEscape/csvLinein@hcengineering/account-client(9 tests).mergeColumnFilters+DEBOUNCE_MSextract (5 tests). A11y basics —html lang,<th scope="col">, drawer closearia-label. Audit empty state component. AdminAudit render cap (200 rows). 10s → 30s throttle on workspace stats poll.9e03bc3948…d97b80f8c0(~4 commits)TokenBucketLimiter, 4 tests).AUDIT_RETENTION_DAYSenv-driven daily prune cron with Mongo auto-disable. V29/V30 migration:batch_id UUID NULLcolumn + partial indexWHERE batch_id IS NOT NULL, split into two migrations because CockroachDB rejects partial-index DDL on a same-tx-added column. Bulk-action service calls stamp one UUID across all their audit rows; UI groups consecutive same-batch rows under a non-interactive header.4da2f06f8e…cff6e8a19e(~6 commits)a267d3d2f4…9461de3092(~5 commits)83b9cdeedb…60e4d27e1d(9 commits)enableAccountInternalextracted (drops N redundantassertAdminper bulk-enable). Workspaces selection-bar count vs Mass Archive count clarified. Orphan-as-clickable-pill with row badge. All stat pills toggleable as filters. "Add workspace" primary button on Workspaces. Export CSV moved next to Presets in the filter row, regular kind. Three-button preset trio collapsed into a singlePresetsdropdown.postgres.test.tsmock SELECT projection updated to match V25–V27 column additions.0c3af29d1c…1d61952b93(8 commits)admin_action_deniedaudit. Detail in the table above.Database migrations
disabled_at,token_version,last_activity_atonaccount+admin_audit_logtableadmin_audit_log.target_accountNULLABLE + 5 query indexesadmin_audit_log.batch_id UUID NULLadmin_audit_log(batch_id) WHERE batch_id IS NOT NULLAll migrations are forward-only with
IF NOT EXISTSguards. Rollbackis "leave the schema, redeploy the previous account pod" — no down-
migrations.
Security posture
decodeFilterParam: strict base64 + JSON-object + recursive__proto__/constructor/prototyperejection + 32-level depth cap. Returns400 Bad Requestwith a structured reason; never silently accepts malformed input.%,_,\) escaped on every user-supplied substring filter (Users search, audit substring filters) with explicitESCAPE '\'clauses.fetch + Authorization: Bearer + blob downloadinstead ofwindow.open(url-with-token). The server retains the legacy?token=…query-string fallback for one release with a deprecation warning log, so any out-of-band scripts that bookmarked the URL keep working. Removing the legacy fallback is in the deferred list.assertAdmingates every admin-only RPC. Single-source-of-truth:ADMIN_EMAILSenv. Force-logout when an active admin is disabled mid-session.Tests
Pass: 575 / 579 across the admin-targeted suites (4 failures in
postgres-real.test.tsare infra-bound, identical atupstream/developmerge-base).server/account(all)postgres-real.test.tsare pre-existing CockroachDB-required integration tests, identical atupstream/developmerge-baseserver/account-service rateLimiterserver/accountadminActionDenied.test.tsplugins/login-resourcestzTooltip.test.tsserver/accountlistAuditAdmin.test.tsfoundations/core/packages/account-client csv + listAccountsAdminplugins/login-resources(mutex, columnFilters, signupTokenGuard)Total new tests added by this PR: ~125 (decodeFilterParam, csv,
columnFilters, rateLimiter, pruneAuditOlderThan, escapeLike,
bulkActions, listAccountsAdmin, listAuditAdmin, getAccountDetails,
assertAdmin, adminActionDenied, tzTooltip).
Reviews completed before this PR
passed spec-compliance review + code-quality review before
integration.
wildcard escape, bulk-enable redundant validation, CSV CRLF + BOM,
429 charset, token-in-URL.
status filter sending wrong field shape, double Popup host, stuck
sort arrow, orphan-mapper drop, header/body alignment, audit filter
UX, ~6 more.
postgres.test.tsmockthat hadn't been updated for the V25–V27 SELECT projection
additions (fixed in
b90bbbcc30).T1 stat-pill filters → T9 Workspaces table. Caught the audit-token
bug (Forbidden on every audit reload), the audit-date end-of-day
cutoff, and the self-disable/self-delete UX gap; all three fixed in
384a81d979andab2589361e.Deliberately deferred (tracked, not blocking this PR)
product/security questions; what's the inverse of
archive_workspacein 6 months? Reset_password has no inverse. Doing this wrong is
worse than not doing it.
?token=…query-string fallback on the CSV exportroute once one release has shipped with the new Authorization-header
flow. Currently emits a deprecation warning when hit.
/admin/export/accounts.csv: iflistAccountsAdminorctx.res.writethrows after the 200 headerhas been sent, the request fails mid-stream without a structured
error envelope. Pre-existing behaviour, not introduced here, but worth
hardening alongside the legacy-token-fallback removal.
DELETEin retention prune — only matters at >10Mrow scale.
BulkPick / AddMember) — refactor candidate, not a feature.
fill/inline/disabledpropsrejected by 6 icon components) — 39 console warnings per page load,
upstream-side cleanup more appropriate than admin-panel side.
Employeerow (in response to theidentities question above).
Live deployment evidence
Deployed and live-verified on a self-hosted Huly v0.7.423 instance
with the CockroachDB backend and OIDC SSO. The following behaviours
have been exercised end-to-end on a 12-user / 14-workspace test
dataset: 5 clickable stat pills with per-pill filter state, drawer
reactive refetch on row swap, outside-click + Escape drawer dismiss,
Workspaces per-row selection + selection-driven Mass Archive,
batch_idrow grouping in the audit log, date-range presets, CSVexport with Authorization header, CSV rate-limit 429 response,
and self-disable UI guard on the admin's own row (Disable button greyed with explanatory label when the caller is the target).
Screenshots in hcengineering/huly-docs#71 (
src/assets/screenshots/huly/admin-panel/).Notes for upstream reviewers
stubs throw "not implemented" with a meaningful error. The audit
retention cron auto-disables itself on Mongo to avoid log spam.
getEmbeddedLabeleverywhere soi18n is a future drop-in.
primitives:
@hcengineering/uiButton / ButtonMenu / Dropdown / CheckBox,@hcengineering/platformgetEmbeddedLabel, postgres.js Sql template tag).develop.We've kept the granular history as documentation, not because we
insist on preserving it.