Org Admin API — console, audit, policy, retention (Phase 14)
TASK-0133. The organization admin surface: the tabbed console's backing API — overview, members, org policy (the five enforcement points), retention (policies + run-now), and the org-scoped audit viewer + CSV export (sync and async). Conventions (error model, auth, IDs) are defined in authentication.md.
Authorization (the console gate)
Every route under /orgs/:orgId/admin/* is behind requireOrgAdmin:
the caller must be a member of the org with role OWNER or ADMIN
(the OrganizationMember row; the owner is also Organization.ownerId).
A MEMBER/GUEST, a non-member, or a member of another org gets
403 { reason: "requires_org_admin" } (the org EXISTS — the reason is
stable so the console renders its gate panel, and cross-org is never
distinguishable from same-org).
There is no other path in: the audit viewer is read-only (no audit-mutation routes exist — audited data is immutable), and every write below is itself an audited event.
| Route | Methods | Purpose |
|---|---|---|
/orgs/:orgId/admin/overview |
GET |
Console Overview tab (counts, 30-day trends, effective policy) |
/orgs/:orgId/admin/workspaces |
GET |
The org's workspaces (the workspace picker for the API-keys / webhooks tabs) |
/orgs/:orgId/admin/members |
GET |
Org members (+ search ?q=) |
/orgs/:orgId/admin/members/:userId |
PATCH |
Role change and/or suspend |
/orgs/:orgId/admin/policy |
GET, PATCH |
Org policy (the five enforcement points) |
/orgs/:orgId/admin/audit |
GET |
Org-scoped audit viewer (filters + pagination) |
/orgs/:orgId/admin/audit/actions |
GET |
Distinct actions in the org's log (the filter datalist) |
/orgs/:orgId/admin/audit/export |
POST |
CSV export (sync ≤ 10k rows, async above) |
/orgs/:orgId/admin/audit/exports/:exportId |
GET |
Export status + the single-use download URL |
/orgs/:orgId/admin/retention |
GET, PATCH |
Per-scope retention policies |
/orgs/:orgId/admin/retention/run |
POST |
Run a scope now (dry or execute) |
/orgs/:orgId/admin/retention/runs/:runId |
GET |
Poll the run report |
Plus the MEMBER-facing (non-admin) effective-policy endpoint:
| Route | Methods | Purpose |
|---|---|---|
/workspaces/:id/policy/effective |
GET |
Org policy + effective caps (file picker, share notice) |
Overview & workspaces
GET …/overview →
{
"memberCount": 3, "activeMemberCount": 1,
"workspaceCount": 2, "pageCount": 41, "databaseCount": 0, "fileCount": 3,
"storageBytes": 1048576,
"storageTrend": [{ "day": "2026-09-04", "bytes": 0 }, …], // 30 days
"auditTrend": [{ "day": "2026-09-04", "count": 7 }, …], // 30 days
"policy": { "maxFileSizeMB": 25, "require2FA": false, "ssoEnforced": false,
"disableGuestPublicShare": false, "allowExternalEmbeds": true,
"sessionLifetimeHours": null }
}
activeMemberCount = members with an active Session in the last 30 days.
GET …/workspaces → { data: [{ id, name, slug, isPersonal }] } — the
picker for the two WORKSPACE-scoped tabs (API keys / webhooks, which
reuse the per-workspace settings API against these ids).
Members
GET …/members?q=<substr> →
{ "data": [
{ "userId": "…", "name": "…", "email": "…", "role": "OWNER",
"status": "ACTIVE", "avatarUrl": null,
"lastLoginAt": "…", "lastActiveAt": "…", "joinedAt": "…" }
] }
lastActiveAt is the user's most recent ACTIVE session (any workspace);
lastLoginAt is the password/SSO login stamp. q matches name/email
(case-insensitive).
PATCH …/members/:userId — body { role?, suspend? } (at least one):
role∈ADMIN | MEMBER | GUEST— neverOWNER(ownership transfer is out of scope in v1; sendingrole: "OWNER"→403 { reason: "ownership_transfer_required" }). Demoting the LAST OWNER →409 { reason: "last_org_owner" }(an org must keep an owner).suspend: true→User.status = SUSPENDED(the shared TASK-0132 deactivation semantics: login and in-flight sessions blocked —403 account_suspended— memberships kept).suspend: falserestores an explicitly suspended account.
Both are audited (org.member_role_changed / org.member_suspended,
with old/new values).
Org policy (the five enforcement points)
One OrgPolicy per org (the Organization.policy JSON column). The
stored values are permissive by default; the plan caps the file
size. GET …/policy → { policy, effective: { maxFileSizeMB, billingPlan } }
(effective.maxFileSizeMB = min(stored, plan, 10240) — the same
computation the upload path enforces).
PATCH …/policy merges the given fields (bounds validated, null
resets to the permissive default):
| Field | Bounds | Enforcement point |
|---|---|---|
require2FA |
bool | Login — any of the user's orgs with the flag → 202 twoFactorRequired + a 5-min single-use TOTP step (the password alone is not enough) |
ssoEnforced |
bool | Login — orgs with a SAML provider + the flag → 403 sso_required (a non-org user is unaffected) |
disableGuestPublicShare |
bool | Share — creating a GUEST (kind: "public") share in the org's workspaces → 403 { reason: "guest_public_share_disabled" } (member/link shares unaffected; existing public shares remain but a re-activation is blocked) |
maxFileSizeMB |
1–10240 (plan-capped) | Upload — presign/PUT over the effective cap → 413 (the member-facing /workspaces/:id/policy/effective returns the cap so the file picker warns BEFORE the pick; presign remains authoritative) |
sessionLifetimeHours |
1–720 | Session — refresh at creation + lifetime → 401 session_expired (the effective lifetime is the MAX across the user's orgs) |
allowExternalEmbeds |
bool | Embed preview — false → the external-site preview proxy 403s BEFORE any fetch (the iframe embed itself is unaffected) |
Policy writes are audited with old/new values — the change trail IS the policy history (documented decision: no separate version table in v1).
Audit viewer
GET …/audit — filters: action (exact), actionPrefix, actorId,
actorType, resourceType, workspaceId, from/to (ISO dates,
window capped at 90 days), page, pageSize (≤ 100, default 25).
Response: { data: [row…], total, page, pageSize } (a row = the full
AuditLog including metadata).
Org scoping (@factory/shared/events/audit-scope, the single source
of truth shared by the viewer, the export, and retention):
AuditLog has no organizationId column — an org's rows are
workspaceId IN (the org's workspaces) OR the org-level branch
(workspaceId IS NULL AND (metadata.orgId = orgId OR
resourceType IN (organization, org) AND resourceId = orgId)).
GET …/audit/actions → { data: ["action.a", …] } (distinct actions in
the org's log, the filter datalist).
CSV export
POST …/audit/export — body = the viewer filters (optional). The
response is content-type-driven (no kind field):
- ≤ 10,000 rows (the sync limit) →
200,text/csv(RFC 4180) with aContent-Disposition: attachmentheader. The CSV is the 13-column header + one line per row (themetadatacolumn is always quoted compact JSON, so lines never contain embedded newlines):id,created_at,actor_type,actor_id,actor_name,workspace_id,workspace_name,resource_type,resource_id,action,ip_address,user_agent,metadata - > 10,000 rows →
202 { exportId, cap: 500000, total }and an enqueued worker job (maintenancequeue,audit.export). One active export per org (409 { reason: "export_in_progress" }while one is processing).
GET …/audit/exports/:exportId →
{ "exportId": "…", "status": "processing | ready | failed",
"rows": 10001, "truncated": false, "createdAt": "…",
"downloadUrl": "https://…/exports/{orgId}/{exportId}.csv?X-Amz-…",
"urlTtlSec": 900 }
downloadUrl is strictly single-use: the first call on a ready
export mints a 15-minute SigV4 URL and flips the row's
downloadUsed; every later call (including ?redownload=1) returns the
status without a URL. The mint is pure signing (no S3 round-trip at
the API). The object lives under exports/{orgId}/{exportId}.csv with a
7-day lifecycle.
Worker side: the job streams the org-scoped rows (keyset cursor, 5k/batch,
the same scope builder as the viewer), renders the CSV, uploads it, flips
the row to ready (or failed), and writes audit.export.completed
/ audit.export.failed with { rows, truncated, key, durationMs }. The
row cap is 500k (truncated: true when hit — unit-tested with an
injectable cap).
Retention
Per-scope policies on the org (RetentionPolicy per (orgId, scope)),
scopes: trash, comments, audit_logs, files. Each scope is
PURGE | HOLD + retainDays. Defaults: trash PURGE 30 d, comments
PURGE 90 d, audit_logs HOLD (legal hold — audit rows are NEVER
purged while it is on), files PURGE 0 d (= no purge).
GET …/retention → { data: [{ scope, type, retainDays, default, lastRun, lastDryRun, updatedAt }] }.
PATCH …/retention body = { scope: { type, retainDays } } (upsert;
audited org.retention_updated with old/new).
POST …/retention/run — body { scope, mode: "dry" | "execute" } →
202 { runId, status: "processing" }. The run is an async worker job
(retention.run on maintenance); the admin's mode is honored
directly — an "execute" run purges even when WORKER_RETENTION_EXECUTE
is off (that flag gates only the DAILY cron's mode, not admin runs).
GET …/retention/runs/:runId → the run report (Redis, 7 d TTL):
{ "orgId": "…", "scope": "trash", "mode": "execute", "status": "purged",
"type": "PURGE", "retainDays": 30, "cutoff": "…",
"counts": { "pages": 3, "blocks": 41, "comments": 0, "files": 0, "bytesFreed": 0 },
"durationMs": 120, "startedAt": "…" }
status is dry_run | purged | skipped (HOLD / no-purge) | failed.
dry_run counts what WOULD be purged (no deletes); purged counts what
was (the audit rows retention.purged {scope, counts} are written per
batch). Per-scope isolation: a throw in one scope fails only that scope
(retention.failed); org isolation: every query is org-scoped by
construction (the shared scope builder).
Member-facing effective policy
GET /workspaces/:id/policy/effective (any workspace member — VIEW):
{ "orgId": "…",
"policy": { "…": "the full OrgPolicy" },
"effective": {
"maxFileSizeBytes": 26214400, "maxFileSizeMB": 25,
"planMaxFileSizeBytes": 52428800,
"sessionLifetimeHours": null, "allowExternalEmbeds": true
} }
The file picker reads this to warn BEFORE the pick (the presign remains
authoritative); the share page shows the disableGuestPublicShare notice
from policy.disableGuestPublicShare (the server stays authoritative —
the notice is UX only).
Error model
All errors follow the shared envelope:
{ error: { code, message, details?: [{ reason }], requestId } }.
Reasons defined by this surface: requires_org_admin,
owner_role_locked, export_in_progress, guest_public_share_disabled,
twoFactorRequired, sso_required, session_expired,
account_suspended (the shared TASK-0132/1 enforcement).