Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,32 @@ export default defineConfig({
},
],
},
{
label: "Admin panel",
collapsed: true,
items: [
{
label: "Overview",
link: "/admin-panel/overview/",
},
{
label: "Users",
link: "/admin-panel/users/",
},
{
label: "Workspaces",
link: "/admin-panel/workspaces/",
},
{
label: "Audit log",
link: "/admin-panel/audit-log/",
},
{
label: "Configuration",
link: "/admin-panel/configuration/",
},
],
},
],
}),
],
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
101 changes: 101 additions & 0 deletions src/content/docs/admin-panel/audit-log.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
title: Audit log
description: Review every administrative action with filtering, sortable columns, and batch grouping.
---

import { Image } from "astro:assets";
import auditDefault from "../../../assets/screenshots/huly/admin-panel/audit-default.png";
import auditFilters from "../../../assets/screenshots/huly/admin-panel/audit-filters.png";
import auditDatePresets from "../../../assets/screenshots/huly/admin-panel/audit-date-presets.png";
import auditDetails from "../../../assets/screenshots/huly/admin-panel/audit-details.png";

Every administrative action — `create_account`, `disable`, `enable`, `set_workspace_member_role`, `archive_workspace`, `trigger_password_reset`, `add_workspace_member`, `remove_member`, `admin_action_denied`, the four `bulk_*` variants, and more — writes a row to the `admin_audit_log` table. The Audit log page is your read-only view of that table with filtering, sorting, and batch grouping for bulk operations.

<Image src={auditDefault} alt="Audit log default view with filter row and grouped batch entries" inferSize quality="max" />

## Default view

The page opens with the **Last 3 days** time range and the latest entries on top. The To-date is inclusive through end-of-day, so entries created earlier today are visible without switching to a Custom range. The render is capped at 200 rows; when more match your filters, a `Showing most recent 200 of N` notice appears at the top.

When switching the preset dropdown from a fixed window to **Custom range**, the From and To dates inherit the previously-active preset's values rather than clearing. This lets an admin tweak one boundary without re-entering both. (The fall-back is a 3-day window when both inputs are empty.)

## Filtering

<Image src={auditFilters} alt="Audit log filter row with admin name input, target name input, actions multi-select, and date range presets" inferSize quality="max" />

The filter row exposes the controls a human reviewer needs, not the raw UUIDs:

- **Admin name or email** — substring match against the actor's name or primary email.
- **Target name or URL** — substring match against the target account's name or the target workspace's URL.
- **Actions** — multi-select dropdown of every action type that exists in the log. Pick one or more to narrow to those.
- **Date range** — preset dropdown with 1 day / 2 days / 3 days / 1 week / 2 weeks / 1 month / Custom.

<Image src={auditDatePresets} alt="Date range preset dropdown open showing Last 1/2/3 days, 1/2 weeks, 1 month, Custom" inferSize quality="max" />

When the preset is anything other than `Custom`, the From/To date inputs are read-only. Switching to `Custom` makes them editable.

The `Reset` button next to the filter row clears every filter and returns to the `Last 3 days` default.

If no rows match, the table shows an empty state with a `Clear filter` shortcut.

## Denied admin actions (V13)

The audit log captures not only successful admin actions but also denied ones, with action type `admin_action_denied`. Two server-side guards write such rows:

- **`cannot_self_disable`** — fires when an admin attempts to disable their own account via the Disable button (the UI greys it out, but the audit row also catches direct RPC calls that bypass the UI).
- **`last_admin`** — fires when an admin attempts to disable the only remaining account whose email appears in `ADMIN_EMAILS` (this would lock the instance out of all admin RPCs).

The audit row's `details` payload includes:

- `reason` — `self_disable` or `last_admin`
- `method` — `disableAccount` (single-target RPC) or `bulkSetDisabled` (bulk RPC; the per-target audit-write fires inside the same RPC, with the bulk-self case audited once before the bulk loop strips the self-target)
- `target` — the account UUID the denied action was aimed at

### Rate-limit

`admin_action_denied` rows are throttled per `(adminUuid, reason, method)` per 1-hour window using an in-memory map. The first denial in a window is audited; subsequent identical denials still throw their platform error to the caller but do not produce duplicate audit rows. This is intentional — the audit log should record the attempt pattern without flooding under an automated probe. The map resets on account-service pod restart (acceptable threat-model trade-off).

### Pre-auth denials are not audited

A request that fails `requireAdmin` (no admin claim on the token, expired session, etc.) does NOT produce an audit row. Writing audit rows pre-auth would itself be an attack surface — an attacker could spam audit rows that name no actor uuid. The platform-level Forbidden response is still returned to the caller in that case.

### Filtering by denial action

The Actions multi-select filter at the top of the page now includes `admin_action_denied`. Selecting it surfaces all denied-attempt rows; combine with the Admin filter to scope to one actor, or the date-range presets to scope to a window.

## Sorting

Click any of the **Time**, **Admin**, **Action**, or **Target** column headers to sort. The arrow indicator only appears on the active sort column.

Each column also has a small filter icon. Clicking it scrolls the page up and focuses the relevant top-bar input — a convenience for quickly narrowing on something you spotted in the table.

### Timestamp tooltip (V8)

Every timestamp cell in the table — both the per-row times and the bulk-action batch-header timestamps — exposes a two-line tooltip on hover:

```
Local time: 2026-05-27 14:33:54 (Europe/Vienna, UTC+1)
UTC: 2026-05-27 13:33:54
```

The Local time uses the browser's resolved timezone (via `Intl.DateTimeFormat`); the UTC line gives an unambiguous absolute reference for cross-region investigations. If the browser doesn't expose `Intl` (very rare), the tooltip degrades to "Local time:" without the zone-name parenthetical.

## Batch grouping

Bulk operations write one audit row per affected target, all stamped with a single `batch_id` UUID. The audit page groups consecutive same-batch rows under a non-interactive header showing the admin who ran the action, the action name, the entry count, and the timestamp. A `bulk_disable` of 50 accounts therefore shows as one collapsible group, not 50 separate rows polluting the log.

Individual (non-bulk) actions are rendered as plain rows without a group header.

## Details column

Each row has a `Details` column that contains the action's structured JSON payload. By default it is collapsed to a `N keys — expand` link so the table stays scannable. Click to view the full JSON:

<Image src={auditDetails} alt="Details column expanded showing JSON payload of an admin action" inferSize quality="max" />

The payload includes the diff or arguments of the action — for example, an `enable_account` row carries `{ accountUuid, previousStatus }`, and a `set_workspace_member_role` row carries `{ workspaceUuid, accountUuid, oldRole, newRole }`.

## Retention

The audit table is automatically pruned. By default, rows older than 365 days are deleted by a daily background job. To change this, set the `AUDIT_RETENTION_DAYS` environment variable on the account service — see [Configuration](/admin-panel/configuration).

Setting `AUDIT_RETENTION_DAYS=0` disables the prune entirely. The job auto-disables itself on the legacy MongoDB backend.
90 changes: 90 additions & 0 deletions src/content/docs/admin-panel/configuration.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: Configuration
description: Environment variables and prerequisites for the Huly admin panel.
---

The admin panel is wired into the account service. Configuration happens through environment variables on the account pod.

## Required: ADMIN_EMAILS

`ADMIN_EMAILS` is a comma-separated list of primary email addresses that are allowed to use the admin panel. Anyone whose Huly account's primary email matches an entry on this list sees the `Admin panel` link in their workbench menu and can call admin RPCs. Everyone else gets a server-side `403`.

```bash
# docker-compose.yml or .env
ADMIN_EMAILS=ops@example.com,security@example.com
```

Notes:

- Matching is case-insensitive.
- Whitespace around entries is trimmed.
- An empty or unset `ADMIN_EMAILS` means **no one** has admin access. Existing accounts that previously had admin access lose it on the next sign-in.
- Removing an email from the list while that admin is signed in does **not** force-log them out — they keep their session until it expires. Their next admin-only RPC call is rejected.
- The reverse — an admin disabling themselves — does force a logout via the same mechanism used for any disabled account.

## Optional: AUDIT_RETENTION_DAYS

`AUDIT_RETENTION_DAYS` controls how long admin audit entries are kept before the daily prune job deletes them.

```bash
AUDIT_RETENTION_DAYS=365 # default
AUDIT_RETENTION_DAYS=90 # keep 90 days only
AUDIT_RETENTION_DAYS=0 # disable prune entirely
```

- Default: `365` (one year).
- `0` disables the prune. Use this if you forward audit rows to an external SIEM before they are deleted, and you want the database to be the source of truth indefinitely.
- The prune job runs once per 24 hours.
- On the legacy MongoDB backend, the timer auto-disables itself the first time the prune call returns `not implemented`, so you do not need to set this to `0` manually.

V13 also adds an `admin_action_denied` audit action; see [Audit log](/admin-panel/audit-log) for the denial reasons and rate-limit behaviour.

## Backend prerequisites

The admin panel only fully works against the PostgreSQL/CockroachDB backend (`CommunityDB`). The implementation uses SQL patterns (typed query builders, partial indexes, batch UUID stamping) that the Mongo-based account store does not implement.

On the Mongo backend:

- Listing endpoints return `not implemented`.
- The admin panel UI shows error states for every section.
- The audit-retention timer auto-disables.

Plan a migration to CockroachDB before relying on the admin panel in production.

## Migrations

The admin panel ships with four forward-only migrations on the `account` database:

| Migration | Adds |
|-----------|------|
| V27 | `account.disabled_at`, `account.token_version`, `account.last_activity_at`, `admin_audit_log` table |
| V28 | `admin_audit_log.target_account` NULLABLE + five query-tuning indexes |
| V29 | `admin_audit_log.batch_id UUID NULL` |
| V30 | Partial index on `admin_audit_log(batch_id) WHERE batch_id IS NOT NULL` |

All four are `IF NOT EXISTS`-guarded and idempotent. They run on the next account-pod startup after upgrade. No downtime is required.

There are no down-migrations. If you need to roll back, leave the schema in place and redeploy the previous account image. The new columns are nullable or have defaults that keep older code paths working.

## Security posture

The admin panel applies several layers of hardening worth knowing about:

- **Server-side admin check** on every admin RPC (`assertAdmin`) — the UI only hides the menu, it does not gate access. A non-admin who manually crafts a request gets a `403`.
- **CSV export rate limit:** 5 requests per minute per token. The token is SHA-256-hashed before being used as the limiter key, so a leaked memory dump does not expose a usable bearer.
- **CSV bearer in header, not URL:** the admin UI uses `fetch + Authorization: Bearer + blob download` instead of `window.open(url-with-token)`. The server still accepts the legacy `?token=…` query parameter for one release with a deprecation warning so out-of-band scripts keep working.
- **Filter param hardening:** the base64-JSON filter parameter parser rejects `__proto__` / `constructor` / `prototype` keys recursively and caps recursion at 32 levels, so a crafted filter cannot reach prototype pollution.
- **LIKE wildcard escape:** all substring filters escape `%`, `_`, and `\` with explicit `ESCAPE '\'` clauses, so a search for `100%` matches the literal string and not "any prefix ending in 1".
- **Force-logout on disable:** when an admin disables an account (including their own), every active session for that account is invalidated within seconds via an `AccountDisabled` transaction sent through the workbench websocket.

### Disable behaviour

The admin panel exposes one account-state action: **Disable**. Setting the disabled flag:

- Writes `disabled_at` on the account row.
- Increments `token_version` to invalidate every active session for that account (force-logout takes effect within seconds of the next request).
- Leaves person, social_id, mailbox secrets, workspace memberships, billing subscription, and per-workspace permissions intact.

Disable is reversible — Enable flips `disabled_at` back to NULL. Re-registration with the same email is implicitly blocked while disabled because the existing account row still occupies the email's `social_id` slot.

The companion code PR originally included a hard-delete action with a database cascade across account, workspace_members, subscription, workspace_permissions, mailbox secrets, and integration secrets. That action was removed before the PR shipped: a hard-delete that does not also visit workspace-side documents (issues, comments, cards) is unsafe by default — it can orphan workspaces whose sole Owner is removed, and it leaves authored content silently re-attributed to the un-verified social_id of the deleted account. Hard-delete, if it returns, will be a separate smaller PR with a content/reference audit, a sole-owner workspace guard, and an impact preview before confirmation.
39 changes: 39 additions & 0 deletions src/content/docs/admin-panel/overview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: Admin panel overview
description: Manage users, workspaces, and view administrative actions in your self-hosted Huly instance.
---

import { Image } from "astro:assets";
import adminSidebar from "../../../assets/screenshots/huly/admin-panel/admin-panel-overview-sidebar.png";

The Huly admin panel gives operators of self-hosted instances a single place to manage accounts, oversee workspaces, and review what other administrators have done. It is intended for self-hosting scenarios where you need to handle account-level operations without dropping to SQL.

## Who can access it

Access is restricted to administrators listed in the `ADMIN_EMAILS` environment variable of the account service. See the [Configuration](/admin-panel/configuration) page for details.

Any account whose primary email matches an entry in `ADMIN_EMAILS` will see an `Admin panel` entry in the workbench menu. Non-admin accounts cannot reach the URL — server-side checks reject every request that is not authenticated as an admin.

## How to open it

Sign in to Huly with an admin account and navigate to `/login/admin` on your instance. From a logged-in workbench, you can also click the `Admin panel` link in the user menu.

The admin panel has its own left sidebar with three sections and a back link to the workbench:

<Image src={adminSidebar} alt="Admin panel sidebar showing Users, Workspaces, and Audit log sections" inferSize quality="max" />

## The three sections

- **[Users](/admin-panel/users)** — list, search, filter, and bulk-action accounts. Open a drawer per account to edit workspace memberships, trigger password resets, disable or enable, and add users to additional workspaces.
- **[Workspaces](/admin-panel/workspaces)** — list, sort, and filter workspaces. Select rows to mass-archive. Open a drawer to see workspace members and remove or re-role them.
- **[Audit log](/admin-panel/audit-log)** — review every administrative action with full filtering, sortable columns, batch grouping for bulk operations, and CSV export.

## What it is not

The admin panel is **not** a workspace-internal settings UI. It does not replace the per-workspace owner settings (people, members, billing). It only exposes the global, instance-wide account operations that previously required direct database access.

It is also **not** a multi-tenancy console — there is only one admin panel per Huly instance, and all listed accounts and workspaces belong to that instance.

## Backend support

The admin panel works against the PostgreSQL/CockroachDB backend (`CommunityDB`). On the legacy MongoDB backend, admin-panel RPCs return a `not implemented` error and the audit-retention timer auto-disables itself to avoid log spam. Self-hosters on Mongo should plan a migration before relying on the admin panel.
Loading