docs/access-control.md
Rama main · réplica de lectura del repositorio original.
```
╔════════════════════════════════════════════════════════════════════════════╗
║ ║
║ ACCESS CONTROL ║
║ Layered, cascading permission resolution — one SQL fragment, 7 priorities║
║ ║
╚════════════════════════════════════════════════════════════════════════════╝
```
# Access Control
Cloud Codex uses a **layered, cascading** permission system. Access is
evaluated from broad/structural (admin → workspace ownership) down to
fine-grained (per-user JSON grants on an archive). The whole cascade resolves
in a single SQL `WHERE` fragment defined in
[`routes/helpers/ownership.js`](../cloudcodex/routes/helpers/ownership.js) —
no in-application loop, no per-step round trip.
---
## Roles and Hierarchy
```
Super Admin
└── Workspace Owner (users FK in workspaces.owner_id)
└── Squad Owner (role = 'owner' in squad_members)
└── Squad Admin (role = 'admin')
└── Squad Member (role = 'member', per-flag perms)
└── Direct Archive Grants
(read_access / write_access JSON arrays)
```
Roles higher in the chain implicitly grant everything below. There is no
group that sits between "workspace owner" and "squad member" beyond the
admin flag.
---
## Archive (Read / Write) Resolution
When checking whether a user can read or write an archive, the system OR-s
together seven conditions inside a single `EXISTS` subquery. **Any match
grants access.** The conditions, in conceptual priority order:
```
┌────────────────────────────────┐
request to read ──► │ is the user a super admin? │ ─── yes ──► ALLOW
or write archive └──────────────┬─────────────────┘
│ no
▼
┌────────────────────────────────┐
│ is user.id in read_access / │ ─── yes ──► ALLOW
│ write_access JSON array? │
└──────────────┬─────────────────┘
│ no
▼
┌────────────────────────────────┐
│ is user the archive creator? │ ─── yes ──► ALLOW
└──────────────┬─────────────────┘
│ no
▼
┌────────────────────────────────┐
│ is user the workspace owner │ ─── yes ──► ALLOW
│ (squads.workspace_id → │
│ workspaces.owner_id = id) ? │
└──────────────┬─────────────────┘
│ no
▼
┌────────────────────────────────┐
│ is user a squad member with │ ─── yes ──► ALLOW
│ role='owner' OR matching │
│ can_read / can_write flag ? │
└──────────────┬─────────────────┘
│ no
▼
┌────────────────────────────────┐
│ is user a member of any squad │ ─── yes ──► ALLOW
│ listed in read_access_squads /│
│ write_access_squads ? │
└──────────────┬─────────────────┘
│ no
▼
┌────────────────────────────────┐
│ is read_access_workspace / │ ─── yes ──► ALLOW
│ write_access_workspace TRUE │
│ AND user in any squad in the │
│ same workspace ? │
└──────────────┬─────────────────┘
│ no
▼
DENY
```
The SQL fragments that enforce this are
`readAccessWhere(alias)` / `writeAccessWhere(alias)` (with parameters from
`readAccessParams(user)` / `writeAccessParams(user)`) in
[`routes/helpers/ownership.js`](../cloudcodex/routes/helpers/ownership.js).
Write access always requires also having read access (write implies read
in practice, since write grants are generally given alongside read grants
by the UI).
---
## Archive Ownership (Management Operations)
Destructive or management operations on an archive (delete, manage access grants) require being an **archive owner**. This check is narrower than general write access:
- Super admin
- Archive creator (`archives.created_by`)
- Workspace owner (via squad → workspace chain)
- Squad member with `role = 'owner'`
---
## Per-Log Access
Individual logs have their own `read_access` and `write_access` JSON columns as well. These are supplemental per-document grants that sit on top of archive-level access. In practice the UI mainly controls access at the archive level, but the data model supports document-level overrides.
---
## The `permissions` Table (Global Feature Flags)
Separate from access to specific content, users have a row in the `permissions` table controlling what they can *create*:
| Permission | Effect |
|-----------------|----------------------------------------------|
| `create_squad` | Can create new squads |
| `create_archive`| Can create new archives |
| `create_log` | Can create documents (TRUE by default) |
**A global flag means "may create", never "may create anywhere."** The workspace
is the tenant boundary. A caller who supplies a workspace or squad id has to be
inside that workspace first: its owner, or a member of one of its squads. Admins
are exempt. A workspace whose owner account was deleted is not a public
workspace, and a squad that belongs to no workspace has no tenant to test
against, so both refuse everyone but an admin.
These global flags are checked next. If a user lacks the global flag, the system also checks:
- Whether they are the workspace owner (bypasses all)
- Whether they have the equivalent squad-member permission (`can_create_archive`, `can_create_log`)
User discovery is bounded the same way, with one deliberate widening.
`GET /api/users/search` shows a non-admin caller themselves, people who share a
workspace with them, the owners of those workspaces, and, only if the caller can
actually invite anyone, accounts that hold no `squad_members` row at all. That
last set is what keeps the squad invite picker working: every account starts
with no squad membership, so membership-only scoping made an SSO or
squad-less-invitation account invisible to everyone but a platform admin.
Say the cost out loud rather than inheriting it: **any account holding
`role IN ('owner','admin')` or `can_manage_members` on any squad, in any
workspace, or owning any workspace of its own, can retrieve the name and email
of every account on the install that has no `squad_members` row**, including
accounts in no workspace of theirs. Everything else stays inside the caller's
own workspaces.
---
## Squad Member Permissions
When a user is added to a squad, they get a row in `squad_members` with granular boolean flags:
| Flag | What it controls |
|-----------------------|------------------------------------------------------|
| `can_read` | View archives and documents in this squad |
| `can_write` | Edit documents in this squad's archives |
| `can_create_log` | Create new documents |
| `can_create_archive` | Create new archives inside this squad |
| `can_manage_members` | Invite/remove members and update their permissions |
| `can_delete_version` | Delete published version snapshots |
| `can_publish` | Publish a new version snapshot |
Flags default to the most restrictive values. When a user is invited, the inviter specifies which flags to grant. Only workspace owners and squad creators can grant `can_manage_members` or invite users as `admin`.
---
## Publish Permission
Publishing a version snapshot has its own dedicated check (`canPublish`). A user can publish if **any** of the following are true:
- The archive has no squad (personal/standalone archive) — always allowed
- Super admin
- Workspace owner
- Squad member with `can_publish = TRUE`
- Squad member with `role = 'owner'`
- Archive creator
---
## 2FA and Session Security
Session tokens are 64-character cryptographically random strings, one per sign-in. The `sessions` table stores only a SHA-256 digest of each as its `id`. They are transmitted via the `Authorization: Bearer <token>` header (or the session cookie for browser redirects: `__Host-sessionToken` on https, `sessionToken` over plain http).
When 2FA is enabled on an account:
- **Email 2FA:** After a valid password login, a 6-digit code is emailed. The session is issued only after the code is verified.
- **TOTP 2FA:** A QR code is shown enabling any authenticator app. The 6-digit TOTP is verified against the stored secret before issuing a session.
Both codes travel by email: the login code for email 2FA, and the disable-confirmation code for *either* method. When mail is disabled on the instance, those two paths refuse with `503` rather than issuing a challenge that can never be answered; no token is minted and no code row is written. TOTP login is unaffected. An admin can recover an account locked out this way without touching the database: `POST /api/admin/users/:id/2fa/reset` clears the 2FA method, the TOTP secret, and any outstanding codes or setup/confirm tokens (`docs/api/admin.md`).
On password change, all sessions for that user except the current one are invalidated.