diff --git a/docs/plan/Groups-version1.md b/docs/plan/Groups-version1.md new file mode 100644 index 00000000..30a8a6ca --- /dev/null +++ b/docs/plan/Groups-version1.md @@ -0,0 +1,671 @@ +# Plan — ReBAC Subject Groups (v1) + +## Context + +OxiCloud's `storage.access_grants` table already declares `subject_type IN +('user', 'group', 'token', 'external')` and `expires_at TIMESTAMPTZ`, but no +schema, code, or UI exists today for the `'group'` subject. This plan +implements that: a nested, root-owned group subject with cascading +authorization, cycle protection, and a global namespace. + +After this lands: +- `Subject::Group(Id)` can be granted permissions on files/folders, with + membership cascading through nested groups. +- A `Subject::User(Id)` is reached via direct grant **OR** via membership in + any group (transitively) that holds a grant. +- One predefined immutable virtual group `Internal` represents *all internal + users* (`is_external = false`), the way "Everyone in your org" works in + Google Workspace. +- Groups are admin-managed (creation, naming, membership). +- Group names are RFC 5321 local-part compliant so the door to a future + mailing-list / email-addressable feature stays open. +- Performance: recursive CTE for transitive expansion, fronted by a 30s Moka + cache keyed by user_id. Designed so a future closure-table migration is a + swap-in behind one function. + +**Decisions accepted earlier in the conversation (encoded in this plan):** +- Max nesting depth: **8**. +- Cycle detection: **at write time** (rejects mutations). +- Cascade-delete grants when a group is deleted. +- No `Everyone` virtual group; external users are only reached via explicit + per-grant action. +- `UseAsSubject` permission acknowledged as future work — v1 ships + admin-only group management (anyone can target any group in a grant). +- Audit events emit via `tracing::info!(target = "audit", ...)`; a syslog + subscriber hook is documented but its concrete wiring is a follow-up. + +## Scope + +### In scope (v1) + +1. New tables `auth.subject_groups` + `auth.subject_group_members`. +2. Predefined `Internal` virtual group (well-known UUID, immutable). +3. CRUD REST API at `/api/groups/...` (admin-only). +4. Membership add/remove with cycle + depth checks. +5. Transitive-expansion function in `AuthorizationEngine`, Moka-cached. +6. Cascade queries in `pg_acl_engine.rs` updated to use `subject_id = ANY(...)`. +7. Share-dialog autocomplete: extend to also return groups (via the new + authenticated `/api/groups/search` endpoint). +8. Audit logging via structured `tracing::info!(target = "audit", ...)`. +9. Minimal i18n: API-returned error message keys only. + +### Out of scope (v2 / later) + +- **Admin UI for group management.** v1 is API-only — `POST /api/groups`, + member add/remove, etc. are reachable via curl / Hurl until a dedicated + admin tab is added in a follow-up. The autocomplete extension in the + *share dialog* (file/folder sharing UX) is the only UI change in v1. +- `Manage` and `UseAsSubject` permissions on groups themselves (delegated + group admin requires adding `subject_group` to the `access_grants` + resource_type CHECK and per-group authz). +- Mailing-list dispatcher (the RFC-compliant naming preserves the door). +- Concrete syslog appender wiring (env-var driven `tracing-syslog` or + `tracing-journald` subscriber — code emits structured events today, + operators choose a sink). +- Closure table for transitive membership (Moka cache is enough; future + swap behind `expand_subject()`). + +## Schema migration + +New file: `migrations/20260612000000_subject_groups.sql`. + +```sql +-- ── auth.subject_groups: root-owned authorization principals ───────────── +CREATE TABLE IF NOT EXISTS auth.subject_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name CITEXT NOT NULL, + description TEXT, + is_virtual BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- RFC 5321 local-part: starts alnum, then alnum/dot/dash/underscore, + -- max 64 chars. Future-proofs `group@instance` mailing-list addressing. + CONSTRAINT subject_groups_name_rfc5321 + CHECK (name ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'), + CONSTRAINT subject_groups_name_uq UNIQUE (name) +); + +CREATE INDEX IF NOT EXISTS idx_subject_groups_is_virtual + ON auth.subject_groups (is_virtual) WHERE is_virtual = TRUE; + +-- ── auth.subject_group_members: edges (user→group or group→group) ──────── +CREATE TABLE IF NOT EXISTS auth.subject_group_members ( + group_id UUID NOT NULL REFERENCES auth.subject_groups(id) ON DELETE CASCADE, + member_user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + member_group_id UUID REFERENCES auth.subject_groups(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by UUID NOT NULL REFERENCES auth.users(id), + + -- Exactly one of the two member columns is set. + CONSTRAINT subject_group_members_xor CHECK ( + (member_user_id IS NOT NULL)::int + (member_group_id IS NOT NULL)::int = 1 + ), + -- A group can't contain itself directly. + CONSTRAINT subject_group_members_no_self CHECK ( + member_group_id IS NULL OR member_group_id <> group_id + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_subject_group_members_user + ON auth.subject_group_members (group_id, member_user_id) + WHERE member_user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_subject_group_members_group + ON auth.subject_group_members (group_id, member_group_id) + WHERE member_group_id IS NOT NULL; + +-- For transitive expansion: "all groups a user belongs to directly" +CREATE INDEX IF NOT EXISTS idx_subject_group_members_by_user + ON auth.subject_group_members (member_user_id) + WHERE member_user_id IS NOT NULL; + +-- For cycle check: "what groups does group X contain (immediate children)" +CREATE INDEX IF NOT EXISTS idx_subject_group_members_by_child_group + ON auth.subject_group_members (member_group_id, group_id) + WHERE member_group_id IS NOT NULL; + +-- ── Seed the predefined `Internal` virtual group ───────────────────────── +-- Well-known UUID hard-coded in Rust so application code can reference it +-- without a runtime lookup: 00000000-0000-0000-0000-000000000001. +INSERT INTO auth.subject_groups (id, name, description, is_virtual) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Internal', + 'All internal users (is_external = false). Membership is implicit; no rows in subject_group_members.', + TRUE +) +ON CONFLICT (id) DO NOTHING; +``` + +**Notes on the schema:** +- `CITEXT` extension is already enabled elsewhere — confirm via + `\dx` against a dev DB; if not, the migration must `CREATE EXTENSION IF NOT + EXISTS citext;` first. +- The two member columns (`member_user_id`, `member_group_id`) form a tagged + union enforced by the XOR check. Simpler than polymorphic FKs. +- The `is_virtual = TRUE` flag distinguishes the immutable `Internal` group; + the service layer rejects member mutations on it. +- No `created_by` column — groups have no owner by design. `added_by` on + *memberships* still records who performed the edit, for audit. + +## Domain layer + +New file: `src/domain/entities/subject_group.rs`. + +```rust +pub struct SubjectGroup { + pub id: Uuid, + pub name: String, + pub description: Option, + pub is_virtual: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub enum GroupMember { + User(Uuid), + Group(Uuid), +} + +impl SubjectGroup { + pub fn new(name: &str, description: Option) -> Result { + Self::validate_name(name)?; + // ... + } + + /// Enforce RFC 5321 local-part shape at the domain layer too (defence in + /// depth — the DB CHECK constraint is the authority). + fn validate_name(name: &str) -> Result<(), DomainError> { + static RE: Lazy = Lazy::new(|| { + Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$").unwrap() + }); + if !RE.is_match(name) { return Err(DomainError::invalid("group.name.rfc5321")); } + Ok(()) + } +} + +pub const INTERNAL_GROUP_ID: Uuid = uuid!("00000000-0000-0000-0000-000000000001"); +pub const MAX_GROUP_DEPTH: u8 = 8; +``` + +New file: `src/domain/repositories/subject_group_repository.rs` (trait). + +Methods: +- `create(group: SubjectGroup) -> Result` +- `get_by_id(id: Uuid) -> Result, DomainError>` +- `get_by_name(name: &str) -> Result, DomainError>` (case-insensitive via CITEXT) +- `list(limit, offset, name_query: Option<&str>) -> Result<(Vec, u64 /*total*/), DomainError>` +- `rename(id: Uuid, new_name: &str) -> Result` +- `delete(id: Uuid) -> Result<(), DomainError>` (cascade-deletes grants via FK from access_grants — TODO confirm; if no FK exists, also delete grants in the same transaction) +- `add_member(group_id: Uuid, member: GroupMember, added_by: Uuid) -> Result<(), DomainError>` +- `remove_member(group_id: Uuid, member: GroupMember) -> Result<(), DomainError>` +- `list_direct_members(group_id: Uuid) -> Result, DomainError>` +- `list_transitive_users(group_id: Uuid) -> Result, DomainError>` (debug/audit) +- `groups_for_user(user_id: Uuid) -> Result, DomainError>` (the hot path — recursive CTE) +- `would_introduce_cycle(parent: Uuid, candidate_child_group: Uuid) -> Result` +- `current_depth(group_id: Uuid) -> Result` (longest path from this group to any leaf) + +## Infrastructure layer + +New file: +`src/infrastructure/repositories/pg/subject_group_pg_repository.rs`. + +Two queries deserve attention because the rest are straight CRUD. + +### Cycle detection (write-time) + +```sql +-- Adding member_group_id=$candidate to group_id=$parent introduces a cycle +-- iff $parent is reachable from $candidate by walking child-edges. +WITH RECURSIVE descendants AS ( + SELECT member_group_id AS g + FROM auth.subject_group_members + WHERE group_id = $candidate AND member_group_id IS NOT NULL + + UNION -- de-dup; the union of disjoint paths is OK + + SELECT m.member_group_id + FROM auth.subject_group_members m + JOIN descendants d ON m.group_id = d.g + WHERE m.member_group_id IS NOT NULL +) +SELECT 1 FROM descendants WHERE g = $parent LIMIT 1; +``` + +If this returns a row → reject with `DomainError::invalid("group.cycle")`. + +### Transitive expansion: groups_for_user + +```sql +WITH RECURSIVE user_groups AS ( + -- Base: direct memberships + SELECT group_id FROM auth.subject_group_members + WHERE member_user_id = $1 + + UNION + + -- Recursive: groups containing those groups + SELECT m.group_id + FROM auth.subject_group_members m + JOIN user_groups ug ON m.member_group_id = ug.group_id +) +SELECT group_id FROM user_groups; +``` + +The depth cap (`MAX_GROUP_DEPTH = 8`) is enforced at *write* time, so this +recursion is bounded by the data — Postgres has no depth limit on the CTE +itself. + +### Depth check (write-time) + +```sql +-- The depth of group $parent after adding $child as a member group = +-- depth-of($parent before mutation) + (1 + depth-of-subtree-rooted-at($child)) +-- Simpler: compute longest path from $parent across the proposed graph and +-- reject if it would exceed 8. +WITH RECURSIVE path AS ( + SELECT group_id AS g, 1 AS depth + FROM auth.subject_group_members WHERE group_id = $parent + UNION + SELECT m.group_id, p.depth + 1 + FROM auth.subject_group_members m + JOIN path p ON m.member_group_id = p.g +) +SELECT COALESCE(MAX(depth), 0) FROM path; +``` + +If the post-mutation projection of this would exceed `MAX_GROUP_DEPTH = 8`, +reject with `DomainError::invalid("group.depth_exceeded")`. + +In practice both checks can be combined in the same transaction, run with +`FOR UPDATE` on the parent group row to prevent concurrent racing mutations +from each squeezing under the limit individually. + +## Application services + +New file: `src/application/services/subject_group_service.rs`. + +Methods mirror the repository trait, plus: +- Each mutator emits one structured audit event (`tracing::info!(target = "audit", ...)`). +- `add_member` runs cycle + depth checks in the same transaction as the insert. +- `delete` is guarded against removing `is_virtual = TRUE` groups. +- Service exposes one fast path: `is_user_in_group(user_id, group_id) -> bool`, + used by the `Internal` group check (special-cased as + `!user.is_external` — no DB hit). + +Wire the service into `AppState::services` in `src/common/di.rs` alongside +the other application services. + +## AuthorizationEngine extension + +Modify `src/application/ports/authorization_ports.rs`: + +Add a new helper on the trait (default impl can be provided in the trait, +overridden by `PgAclEngine`): + +```rust +/// Returns the caller plus the IDs of every group they belong to transitively, +/// plus the predefined `INTERNAL_GROUP_ID` when the caller is not external. +/// This is the single place transitive membership is walked — all v1 +/// callers, and the future closure-table swap-in, go through this function. +async fn expand_subject(&self, user_id: Uuid) -> Result>, DomainError>; +``` + +Modify `src/infrastructure/services/pg_acl_engine.rs`: + +1. Add a Moka cache field on the struct: + + ```rust + user_groups_cache: moka::future::Cache>>, + ``` + + constructed with: + + ```rust + Cache::builder() + .max_capacity(50_000) + .time_to_live(Duration::from_secs(30)) + .build(); + ``` + +2. Implement `expand_subject`: + + ```rust + async fn expand_subject(&self, user_id: Uuid) -> Result>, DomainError> { + if let Some(cached) = self.user_groups_cache.get(&user_id).await { + return Ok(cached); + } + let direct = self.repo.groups_for_user(user_id).await?; // recursive CTE + let mut set = HashSet::with_capacity(direct.len() + 2); + set.insert(user_id); + set.extend(direct); + // Internal virtual group: implicit for every non-external user. + if !self.users.is_external(user_id).await? { + set.insert(INTERNAL_GROUP_ID); + } + let arc = Arc::new(set); + self.user_groups_cache.insert(user_id, arc.clone()).await; + Ok(arc) + } + ``` + +3. Modify the existing cascade queries (`folder_cascade_grant_exists` at + lines 92–121 and `file_cascade_grant_exists` at lines 125–168 of + `pg_acl_engine.rs`): + + Replace `g.subject_id = $2` with `g.subject_id = ANY($2)` and bind a + `Vec` produced by `expand_subject(user_id).await?.iter().copied().collect()`. + + Subject_type must also be relaxed: today the query passes + `subject_type = 'user'`. With groups, the helper should match against + `subject_type IN ('user', 'group')`. (Tokens and externals are not part + of this path; they have their own auth flows.) + +The shape of the rest of the query — and the folder/file ltree cascade — is +unchanged. The closure-table migration (future) will only re-implement +`groups_for_user` against a precomputed table; callers stay the same. + +## REST API + +New file: `src/interfaces/api/handlers/subject_group_handler.rs`. +Wire into `src/interfaces/api/routes.rs` alongside `admin_handler`. + +| Method | Route | Body / Query | Guard | +|---|---|---|---| +| POST | `/api/groups` | `{ name, description? }` | admin only | +| GET | `/api/groups` | `?limit&offset&q` | admin only | +| GET | `/api/groups/{id}` | — | admin only | +| PATCH | `/api/groups/{id}` | `{ name?, description? }` | admin only | +| DELETE | `/api/groups/{id}` | — | admin only | +| POST | `/api/groups/{id}/members` | `{ user_id?, group_id? }` (XOR) | admin only | +| GET | `/api/groups/{id}/members` | direct members | admin only | +| GET | `/api/groups/{id}/effective-members` | transitive resolved users | admin only | +| DELETE | `/api/groups/{id}/members/user/{uid}` | — | admin only | +| DELETE | `/api/groups/{id}/members/group/{gid}` | — | admin only | +| GET | `/api/groups/{id}/grants` | grants where this group is subject | admin only | +| GET | `/api/groups/{id}/path-to-user/{uid}` | audit: explain membership | admin only | + +Plus the share-dialog endpoint extension: + +| Method | Route | Body / Query | Guard | +|---|---|---|---| +| GET | `/api/groups/search` | `?q=` returns non-virtual groups whose name matches | authenticated | + +This new search endpoint is **authenticated, not admin-gated** — any user can +discover groups to share with. Returns name + id only (no membership list). + +Admin guard implementation: mirror `admin_handler.rs:64-100` exactly (extract +JWT, check `claims.role == "admin"`, 403 otherwise). Extract into a shared +helper `require_admin(state, headers) -> Result<(Uuid, String), AppError>` +in `interfaces/middleware/` so the new handler and `admin_handler` both use +the same code path. + +## Audit logging + +Convention: every mutating service-layer action emits one +`tracing::info!(target = "audit", ...)` event with structured fields. +Example: + +```rust +tracing::info!( + target: "audit", + event = "group.member_added", + group_id = %group_id, + member_user_id = ?member_user_id, + member_group_id = ?member_group_id, + added_by = %caller_id, +); +``` + +Events to emit: +- `group.created` { group_id, name, created_by } +- `group.renamed` { group_id, old_name, new_name, by } +- `group.deleted` { group_id, name, by } +- `group.member_added` { group_id, member, by } +- `group.member_removed` { group_id, member, by } +- `group.cycle_rejected` { parent, candidate_child, by } (security-relevant) +- `group.depth_exceeded` { parent, by, attempted_depth } + +The plan does *not* wire a syslog appender; downstream operators add a +`tracing-syslog` or `tracing-journald` subscriber via env-var-driven config. +A follow-up issue should be opened for that. + +## Debug instrumentation (perf observability) + +Distinct from the audit log: every authorization check emits one structured +`tracing::debug!` line with timing and cache-hit telemetry so the closure- +table-vs-cache decision (Option 2 → Option 3 in the design doc) can be made +on real data rather than speculation. + +Implementation: wrap each call to `AuthorizationEngine::check` / +`AuthorizationEngine::expand_subject` in a tracing span and increment +per-call counters. Suggested shape: + +```rust +impl PgAclEngine { + async fn check(&self, subject: Subject, perm: Permission, resource: Resource) + -> Result + { + let start = std::time::Instant::now(); + let counters = QueryCounters::default(); + + let result = self.check_inner(subject, perm, resource, &counters).await; + + tracing::debug!( + event = "authz.check", + subject = ?subject, + permission = ?perm, + resource = ?resource, + allowed = result.as_ref().ok().copied().unwrap_or(false), + duration_us = start.elapsed().as_micros() as u64, + cache_hit = counters.cache_hit.load(Ordering::Relaxed), + sql_queries = counters.sql_queries.load(Ordering::Relaxed), + expanded_groups = counters.expanded_group_count.load(Ordering::Relaxed), + ); + + result + } +} +``` + +Where `QueryCounters` is a tiny struct of `AtomicU32`s passed through the +call chain, incremented at each `sqlx::query*` call site inside the authz +path. `cache_hit` is set by `expand_subject` based on whether the Moka +`Cache::get` returned `Some`. + +**What this gives you:** + +| Field | Use | +|---|---| +| `duration_us` | latency histogram per check; alert on p99 regression | +| `cache_hit` | hit-rate metric → decide when to extend TTL or switch to closure table | +| `sql_queries` | 0 on cache hit; 1 on cache miss + grant lookup; 2 if cache miss + transitive expansion + grant lookup — confirms the query plan in production | +| `expanded_groups` | size of the user's transitive group set; if this stays small in practice, the recursive CTE is more than enough | + +**Cost:** sub-microsecond per check (atomic increments + a single +`tracing::debug!` emission, which becomes a no-op when the subscriber is at +INFO or higher). No runtime cost in production unless debug logging is +explicitly enabled. + +**Recommended deployment hook:** an env var `OXICLOUD_AUTHZ_DEBUG=true` that +flips the subscriber filter to allow `target="oxicloud::authz" level=debug` +events through. Operators turn it on temporarily when investigating +performance issues; default is INFO and emits nothing from this path. + +## Share dialog extension + +Modify `static/js/components/shareModal.js` around line 310. + +Currently it calls `addressBook.searchContacts(q, [SYSTEM_BOOK_ID])`. Add a +parallel call to `fetch('/api/groups/search?q=' + encodeURIComponent(q))`. +Merge the two result lists, tag each item by source (`user` vs `group` +vs `contact`), and render with the appropriate icon (user avatar / +`fa-layer-group` / contact card). + +On selection, dispatch to the existing grant-creation flow with the +correct `subject_type`: +- `user` → `subject_type = 'user'`, `subject_id = user.id` +- `group` → `subject_type = 'group'`, `subject_id = group.id` +- `contact` → resolved through the existing address-book mapper to the + matching user_id (no change from today) + +## i18n + +Add to `static/locales/en.json` (errors surfaced by the API + share-dialog): + +```json +"errors": { + "group_name_invalid": "Group name must match the email-prefix format (letters, digits, dot, dash, underscore; 1–64 chars).", + "group_cycle": "This member would create a circular group reference.", + "group_depth_exceeded": "This nesting depth exceeds the maximum allowed (8).", + "group_virtual_immutable": "The 'Internal' group is system-managed and cannot be modified.", + "group_not_found": "Group not found." +} +``` + +These keys are referenced by `ApiError` payloads from the new handler and +by the share-dialog UI when a target group is invalid. Sync the 15 locale +files using the Python script pattern from the earlier i18n turn. + +The full set of admin-table labels (`admin.tab_groups`, `admin.col_*`, etc.) +is **deferred to the v2 admin UI work** along with the rest of the admin +surface for groups. + +## Tests + +### Unit tests + +Module: `src/infrastructure/repositories/pg/subject_group_pg_repository.rs#tests` + +1. `test_create_group_validates_name_rfc5321` — names with spaces / emojis + rejected; valid names accepted. +2. `test_group_name_unique_case_insensitive` — "Engineering" and + "engineering" collide (CITEXT). +3. `test_cycle_check_rejects_direct_loop` — adding A to A rejected by the + `no_self` CHECK or by the cycle CTE. +4. `test_cycle_check_rejects_two_step_loop` — A∋B, B∋C, attempting C∋A + rejected. +5. `test_cycle_check_rejects_eight_step_loop` — same with longer chain. +6. `test_depth_cap_at_8` — adding a 9th level rejected. +7. `test_transitive_expansion_includes_indirect_groups` — A∋B, B∋C, U∈A + returns {A, B, C} (plus U and Internal). +8. `test_internal_group_implicit_for_internal_users` — non-external user's + expansion contains `INTERNAL_GROUP_ID`; external user's doesn't. +9. `test_virtual_group_cannot_be_deleted` — service rejects delete on the + Internal group. +10. `test_member_can_be_user_or_group_but_not_both` — XOR check. + +### Integration tests + +11. `test_authz_cascades_through_group` — Alice in group G; G has read grant + on file F; AuthorizationEngine::check returns Allow. +12. `test_authz_cascades_through_nested_group` — Alice in B, B in A, A has + grant. Expect Allow. +13. `test_grant_revoked_when_group_deleted` — delete G; previous + G-mediated grants no longer apply (FK CASCADE). +14. `test_user_removed_from_group_loses_access_after_cache_ttl` — remove + Alice from G; within 30s old answer may persist; after TTL, denied. +15. `test_internal_group_grant_visible_to_all_internal` — grant `read` on + file F to `INTERNAL_GROUP_ID`; every internal user can read F; no + external user can. + +### API tests (Hurl) + +16. `tests/api/groups_admin_only.hurl` — non-admin POST /api/groups → 403. +17. `tests/api/groups_crud_happy_path.hurl` — create, list, get, rename, + delete. +18. `tests/api/groups_member_lifecycle.hurl` — add user, add nested + group, remove user, remove nested group. +19. `tests/api/groups_invalid_name.hurl` — name with space → 400. + +## Verification + +Pre-commit: + +```bash +cargo fmt --all +cargo clippy --all-features --all-targets -- -D warnings +cargo test --workspace +biome check --fix static/js/ +stylelint static/css/ +tsc -p jsconfig.json --noEmit +``` + +End-to-end smoke test (manual; v1 is API-driven): + +1. `docker compose up -d postgres`. +2. `cargo run`. +3. Obtain an admin JWT (log in via the existing login flow, copy the access + token). +4. Create a group: + ``` + curl -X POST /api/groups -H 'Authorization: Bearer …' \ + -d '{"name":"engineering"}' + ``` +5. Confirm name validation rejects `Engineering Team` (returns 400 with + `error_code: group_name_invalid`). +6. Add yourself as a member: + ``` + curl -X POST /api/groups//members -d '{"user_id":""}' + ``` +7. Create a second group `qa`, then add `engineering` as a nested member of + `qa`. Confirm with `GET /api/groups//members`. +8. Attempt to add `qa` as a member of `engineering` — expect 400 with + `error_code: group_cycle`. +9. In the browser, open a file → share dialog → type `eng`. The + `engineering` group should appear with the layer-group icon. Pick it + and grant `read`. +10. Log in as a user who is a member of `engineering` (directly or via + `qa` cascading) — confirm the file is accessible. +11. `DELETE /api/groups//members/user/`. After 30 seconds (cache + TTL), confirm access is denied. +12. Grant `read` on a file to the `Internal` virtual group (`subject_id` + = `00000000-0000-0000-0000-000000000001`). Confirm every internal user + has access. Confirm an external user (if available) does not. +13. `journalctl -t oxicloud | grep audit` (or equivalent log inspection) — + confirm one structured log line per group mutation, with the + `target="audit"` and `event="group.*"` fields. + +## Critical files to be modified + +**New files:** +- `migrations/20260612000000_subject_groups.sql` +- `src/domain/entities/subject_group.rs` +- `src/domain/repositories/subject_group_repository.rs` +- `src/infrastructure/repositories/pg/subject_group_pg_repository.rs` +- `src/application/services/subject_group_service.rs` +- `src/interfaces/api/handlers/subject_group_handler.rs` +- `tests/api/groups_*.hurl` + +**Modified files:** +- `src/application/ports/authorization_ports.rs` — add `expand_subject`. +- `src/infrastructure/services/pg_acl_engine.rs` — add Moka cache field, + implement `expand_subject`, modify `folder_cascade_grant_exists` (lines + 92–121) and `file_cascade_grant_exists` (lines 125–168) to use + `subject_id = ANY($caller_plus_groups)`. +- `src/common/di.rs` — wire `SubjectGroupService` into `AppState`, pass + user-repo into `PgAclEngine` constructor. +- `src/interfaces/api/routes.rs` — register the new handler. +- `src/interfaces/middleware/` — extract `require_admin` shared helper + from `admin_handler.rs:64-100`. +- `static/js/components/shareModal.js` — parallel `/api/groups/search` + call around line 310 to surface groups in the recipient autocomplete. +- `static/locales/en.json` + 15 locale files — new `errors.group_*` keys + for API error rendering. + +(No changes to `static/admin.html` or `static/js/views/admin/admin.js` in +v1 — admin UI is v2 work.) + +## Reused utilities + +- `AppState.authorization` (Arc) — existing DI wiring. +- `admin_handler::admin_guard` pattern (`admin_handler.rs:64-100`) — extract + shared. +- `moka` 0.12.15 (`Cargo.toml:38`) — already present. +- `tracing::info!` — existing observability pipeline; just add the + `target: "audit"` convention. +- `ResourceListComponent` and `userVignette` — already used by other + admin tables; reuse for the Groups admin table. +- Recursive CTE pattern — new to OxiCloud but standard Postgres. +- `auth.users.role = 'admin'` ENUM check — admin guard. diff --git a/migrations/20260612000000_subject_groups.sql b/migrations/20260612000000_subject_groups.sql new file mode 100644 index 00000000..2486ef2b --- /dev/null +++ b/migrations/20260612000000_subject_groups.sql @@ -0,0 +1,90 @@ +-- ───────────────────────────────────────────────────────────────────────── +-- ReBAC Subject Groups — root-owned authorization principals with cascading +-- membership (User ∈ Group, Group ∈ Group). +-- +-- Granting permission on a file/folder to a subject_group cascades to every +-- user transitively a member of that group. Cycles are forbidden (checked +-- at write time by application code). Group names follow RFC 5321 local-part +-- shape so future mailing-list addressing (`@instance`) is possible. +-- +-- Companion code: src/domain/entities/subject_group.rs and +-- src/infrastructure/services/pg_acl_engine.rs (expand_subject). +-- ───────────────────────────────────────────────────────────────────────── + +-- Case-insensitive uniqueness on group name (handles "Eng" vs "eng" collision). +CREATE EXTENSION IF NOT EXISTS citext; + +-- ── auth.subject_groups ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS auth.subject_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name CITEXT NOT NULL, + description TEXT, + is_virtual BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- RFC 5321 local-part: starts alnum, then alnum/dot/dash/underscore, + -- max 64 chars. Future-proofs `@instance` mailing-list addressing. + CONSTRAINT subject_groups_name_rfc5321 + CHECK (name ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'), + CONSTRAINT subject_groups_name_uq UNIQUE (name) +); + +CREATE INDEX IF NOT EXISTS idx_subject_groups_is_virtual + ON auth.subject_groups (is_virtual) WHERE is_virtual = TRUE; + +-- ── auth.subject_group_members ────────────────────────────────────────── +-- Tagged-union row: exactly one of (member_user_id, member_group_id) is set. +CREATE TABLE IF NOT EXISTS auth.subject_group_members ( + group_id UUID NOT NULL REFERENCES auth.subject_groups(id) ON DELETE CASCADE, + member_user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + member_group_id UUID REFERENCES auth.subject_groups(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by UUID NOT NULL REFERENCES auth.users(id), + + -- Exactly one of the two member columns is set. + CONSTRAINT subject_group_members_xor CHECK ( + (member_user_id IS NOT NULL)::int + (member_group_id IS NOT NULL)::int = 1 + ), + -- A group can't contain itself directly (cycles of length > 1 are + -- rejected at write time by the application layer's recursive-CTE check). + CONSTRAINT subject_group_members_no_self CHECK ( + member_group_id IS NULL OR member_group_id <> group_id + ) +); + +-- Each (group, user_member) pair unique. +CREATE UNIQUE INDEX IF NOT EXISTS idx_subject_group_members_user + ON auth.subject_group_members (group_id, member_user_id) + WHERE member_user_id IS NOT NULL; + +-- Each (group, group_member) pair unique. +CREATE UNIQUE INDEX IF NOT EXISTS idx_subject_group_members_group + ON auth.subject_group_members (group_id, member_group_id) + WHERE member_group_id IS NOT NULL; + +-- Hot path: "all groups a user belongs to directly" (base step of +-- groups_for_user recursive CTE). +CREATE INDEX IF NOT EXISTS idx_subject_group_members_by_user + ON auth.subject_group_members (member_user_id) + WHERE member_user_id IS NOT NULL; + +-- Cycle / transitive-expansion: "what groups contain group X as a member". +CREATE INDEX IF NOT EXISTS idx_subject_group_members_by_child_group + ON auth.subject_group_members (member_group_id, group_id) + WHERE member_group_id IS NOT NULL; + +-- ── Seed the predefined `Internal` virtual group ──────────────────────── +-- Well-known UUID hard-coded in Rust (`INTERNAL_GROUP_ID` constant in +-- src/domain/entities/subject_group.rs) so application code can reference +-- it without a runtime lookup. Membership is *implicit*: every non-external +-- user is treated as a member by pg_acl_engine::expand_subject; no rows in +-- subject_group_members exist for this group. +INSERT INTO auth.subject_groups (id, name, description, is_virtual) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Internal', + 'All internal users (is_external = false). Membership is implicit; no rows in subject_group_members.', + TRUE +) +ON CONFLICT (id) DO NOTHING; diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index bfa7085d..207736df 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -23,6 +23,7 @@ pub mod share_browse_service; pub mod share_service; pub mod storage_settings_service; pub mod storage_usage_service; +pub mod subject_group_service; pub mod trash_service; pub mod wopi_lock_service; pub mod wopi_token_service; diff --git a/src/application/services/subject_group_service.rs b/src/application/services/subject_group_service.rs new file mode 100644 index 00000000..02c0ee89 --- /dev/null +++ b/src/application/services/subject_group_service.rs @@ -0,0 +1,382 @@ +//! Subject group application service. +//! +//! Orchestrates CRUD and membership for ReBAC subject groups on top of the +//! `SubjectGroupRepository`. This is where: +//! - Name validation runs (defence-in-depth alongside the DB CHECK). +//! - Virtual groups (e.g. `Internal`) are protected from mutation. +//! - Audit events are emitted via `tracing::info!(target = "audit", ...)`. +//! - Cascading delete of `storage.access_grants` rows referencing this +//! group runs in the same transaction as the group delete. +//! +//! See `migrations/20260612000000_subject_groups.sql` for the schema. + +use std::collections::HashSet; +use std::sync::Arc; + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::subject_group::{ + GroupMember, INTERNAL_GROUP_ID, SubjectGroup, SubjectGroupError, +}; +use crate::domain::repositories::subject_group_repository::{ + SubjectGroupRepository, SubjectGroupRepositoryError, +}; +use crate::infrastructure::repositories::pg::SubjectGroupPgRepository; + +pub struct SubjectGroupService { + repo: Arc, + pool: Arc, +} + +impl SubjectGroupService { + pub fn new(repo: Arc, pool: Arc) -> Self { + Self { repo, pool } + } + + /// Create a new group. Validates the name (RFC 5321 local-part shape) + /// at the domain layer before the round-trip; the DB CHECK constraint + /// is the ultimate authority. + pub async fn create( + &self, + name: &str, + description: Option, + caller_id: Uuid, + ) -> Result { + let group = SubjectGroup::new(name, description).map_err(map_entity_err)?; + let saved = self.repo.create(&group).await.map_err(map_repo_err)?; + + tracing::info!( + target: "audit", + event = "group.created", + group_id = %saved.id, + name = %saved.name, + created_by = %caller_id, + ); + + Ok(saved) + } + + pub async fn get_by_id(&self, id: Uuid) -> Result { + match self.repo.get_by_id(id).await.map_err(map_repo_err)? { + Some(g) => Ok(g), + None => Err(DomainError::new( + ErrorKind::NotFound, + "SubjectGroup", + format!("group {} not found", id), + )), + } + } + + pub async fn list( + &self, + limit: u32, + offset: u32, + name_query: Option<&str>, + ) -> Result<(Vec, u64), DomainError> { + self.repo + .list(limit, offset, name_query) + .await + .map_err(map_repo_err) + } + + /// Same as `list`, with the direct-member count attached to each row. + /// Used by the management UI; the share-dialog search path stays on the + /// lighter `search_for_share` which doesn't need counts. + pub async fn list_with_counts( + &self, + limit: u32, + offset: u32, + name_query: Option<&str>, + ) -> Result<(Vec<(SubjectGroup, i64)>, u64), DomainError> { + self.repo + .list_with_counts(limit, offset, name_query) + .await + .map_err(map_repo_err) + } + + /// Direct-member count for a single group. Cheap (one `COUNT(*)`); used + /// by create / get / update endpoints so the response DTO carries the + /// same `member_count` field as the list view. + pub async fn count_members(&self, id: Uuid) -> Result { + self.repo.count_members(id).await.map_err(map_repo_err) + } + + /// Search by name prefix/substring. Virtual groups (Internal, plus any + /// future predefined entries) are included so the share-dialog + /// autocomplete picks them up automatically — no frontend change is + /// needed when a new virtual group is added server-side. The repository + /// returns virtual groups first so they're discoverable when the query + /// is empty / short. + pub async fn search_for_share( + &self, + query: &str, + limit: u32, + ) -> Result, DomainError> { + let (rows, _total) = self + .repo + .list(limit, 0, Some(query)) + .await + .map_err(map_repo_err)?; + Ok(rows) + } + + pub async fn rename( + &self, + id: Uuid, + new_name: &str, + caller_id: Uuid, + ) -> Result { + // Block mutation on virtual groups (the Internal sentinel). + let existing = self.get_by_id(id).await?; + if existing.is_virtual { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "SubjectGroup", + "virtual groups cannot be modified".to_string(), + )); + } + + SubjectGroup::validate_name(new_name).map_err(map_entity_err)?; + let renamed = self.repo.rename(id, new_name).await.map_err(map_repo_err)?; + + tracing::info!( + target: "audit", + event = "group.renamed", + group_id = %renamed.id, + old_name = %existing.name, + new_name = %renamed.name, + by = %caller_id, + ); + + Ok(renamed) + } + + /// Delete the group; cascades to: + /// - `auth.subject_group_members` rows (FK CASCADE). + /// - `storage.access_grants` rows where `subject_type='group'` and + /// `subject_id = id` (handled here, no FK exists between + /// `access_grants` and `subject_groups`). + pub async fn delete(&self, id: Uuid, caller_id: Uuid) -> Result<(), DomainError> { + let existing = self.get_by_id(id).await?; + if existing.is_virtual { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "SubjectGroup", + "virtual groups cannot be modified".to_string(), + )); + } + + // Atomically delete grants pointing at this group, then the group + // itself. If either fails, both roll back. + let mut tx = self.pool.begin().await.map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "SubjectGroup", + format!("begin tx: {}", e), + ) + })?; + + let grants_deleted = sqlx::query( + "DELETE FROM storage.access_grants + WHERE subject_type = 'group' AND subject_id = $1", + ) + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "SubjectGroup", + format!("cascade-delete grants: {}", e), + ) + })? + .rows_affected(); + + let removed = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1") + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "SubjectGroup", + format!("delete group: {}", e), + ) + })? + .rows_affected(); + + if removed == 0 { + return Err(DomainError::new( + ErrorKind::NotFound, + "SubjectGroup", + format!("group {} not found", id), + )); + } + + tx.commit().await.map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "SubjectGroup", + format!("commit: {}", e), + ) + })?; + + tracing::info!( + target: "audit", + event = "group.deleted", + group_id = %id, + name = %existing.name, + grants_cascade_deleted = grants_deleted, + by = %caller_id, + ); + + Ok(()) + } + + pub async fn add_member( + &self, + group_id: Uuid, + member: GroupMember, + caller_id: Uuid, + ) -> Result<(), DomainError> { + if group_id == INTERNAL_GROUP_ID { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "SubjectGroup", + "Internal group membership is implicit and cannot be edited".to_string(), + )); + } + + self.repo + .add_member(group_id, member, caller_id) + .await + .map_err(|e| { + // Emit a security-relevant audit event on cycle / depth + // rejections so abusive admin behaviour is captured. + match &e { + SubjectGroupRepositoryError::Cycle(msg) => { + tracing::info!( + target: "audit", + event = "group.cycle_rejected", + group_id = %group_id, + member = ?member, + detail = %msg, + by = %caller_id, + ); + } + SubjectGroupRepositoryError::DepthExceeded(msg) => { + tracing::info!( + target: "audit", + event = "group.depth_exceeded", + group_id = %group_id, + member = ?member, + detail = %msg, + by = %caller_id, + ); + } + _ => {} + } + map_repo_err(e) + })?; + + tracing::info!( + target: "audit", + event = "group.member_added", + group_id = %group_id, + member = ?member, + by = %caller_id, + ); + + Ok(()) + } + + pub async fn remove_member( + &self, + group_id: Uuid, + member: GroupMember, + caller_id: Uuid, + ) -> Result<(), DomainError> { + if group_id == INTERNAL_GROUP_ID { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "SubjectGroup", + "Internal group membership is implicit and cannot be edited".to_string(), + )); + } + + self.repo + .remove_member(group_id, member) + .await + .map_err(map_repo_err)?; + + tracing::info!( + target: "audit", + event = "group.member_removed", + group_id = %group_id, + member = ?member, + by = %caller_id, + ); + + Ok(()) + } + + pub async fn list_direct_members( + &self, + group_id: Uuid, + ) -> Result, DomainError> { + self.repo + .list_direct_members(group_id) + .await + .map_err(map_repo_err) + } + + pub async fn list_transitive_users(&self, group_id: Uuid) -> Result, DomainError> { + self.repo + .list_transitive_users(group_id) + .await + .map_err(map_repo_err) + } + + /// Hot path used by `PgAclEngine::expand_subject`. Returns the set of + /// groups `user_id` belongs to transitively (excluding the implicit + /// `INTERNAL_GROUP_ID` — the caller adds that). + pub async fn groups_for_user(&self, user_id: Uuid) -> Result, DomainError> { + self.repo + .groups_for_user(user_id) + .await + .map_err(map_repo_err) + } +} + +fn map_entity_err(e: SubjectGroupError) -> DomainError { + let (kind, msg) = match e { + SubjectGroupError::InvalidName(m) => (ErrorKind::InvalidInput, m), + SubjectGroupError::CycleDetected(m) => (ErrorKind::InvalidInput, m), + SubjectGroupError::DepthExceeded(m) => (ErrorKind::InvalidInput, m), + SubjectGroupError::VirtualImmutable(m) => (ErrorKind::AccessDenied, m), + SubjectGroupError::ValidationError(m) => (ErrorKind::InvalidInput, m), + }; + DomainError::new(kind, "SubjectGroup", msg) +} + +fn map_repo_err(e: SubjectGroupRepositoryError) -> DomainError { + let (kind, msg) = match e { + SubjectGroupRepositoryError::NotFound(m) => (ErrorKind::NotFound, m), + SubjectGroupRepositoryError::NameAlreadyExists(m) => (ErrorKind::AlreadyExists, m), + SubjectGroupRepositoryError::InvalidName(m) => (ErrorKind::InvalidInput, m), + SubjectGroupRepositoryError::Cycle(m) => (ErrorKind::InvalidInput, m), + SubjectGroupRepositoryError::DepthExceeded(m) => (ErrorKind::InvalidInput, m), + SubjectGroupRepositoryError::VirtualImmutable(m) => (ErrorKind::AccessDenied, m), + SubjectGroupRepositoryError::MemberAlreadyPresent => ( + ErrorKind::AlreadyExists, + "member already in group".to_string(), + ), + SubjectGroupRepositoryError::MemberNotPresent => { + (ErrorKind::NotFound, "member not in group".to_string()) + } + SubjectGroupRepositoryError::StorageError(m) => (ErrorKind::InternalError, m), + }; + DomainError::new(kind, "SubjectGroup", msg) +} diff --git a/src/common/di.rs b/src/common/di.rs index d107eb1b..b0e747e1 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -638,10 +638,16 @@ impl AppServiceFactory { // 3a. Authorization engine — must exist before application services // because services hold an Arc for ReBAC checks. + // SubjectGroupPgRepository is constructed here too so the engine can + // expand a user's transitive group set on cache misses. + let subject_group_repo = Arc::new( + crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()), + ); let authorization = build_authorization_engine( pool.clone(), repos.folder_repository.clone(), repos.file_read_repository.clone(), + subject_group_repo.clone(), ); // 3b. Trash service (needed before application services) @@ -822,6 +828,12 @@ impl AppServiceFactory { webdav_lock_store: crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), authorization, + subject_group_service: Some(Arc::new( + crate::application::services::subject_group_service::SubjectGroupService::new( + subject_group_repo.clone(), + pool.clone(), + ), + )), }; // 9b. Wire admin settings service when auth is available @@ -1147,6 +1159,10 @@ pub struct AppState { /// an enum dispatcher or `Arc` (with /// `async_trait` boxing). pub authorization: Arc, + /// ReBAC subject-group management (CRUD + membership). `None` when the + /// auth subsystem is not configured. + pub subject_group_service: + Option>, } // All AppState construction is done via struct literal in build_app_state(). @@ -1162,6 +1178,7 @@ fn build_authorization_engine( file_repo: Arc< crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository, >, + group_repo: Arc, ) -> Arc { use crate::infrastructure::services::pg_acl_engine::PgAclEngine; @@ -1173,5 +1190,5 @@ fn build_authorization_engine( "OXICLOUD_AUTHZ_ENGINE={other:?} is not yet supported. Only 'postgres' is implemented; leave the variable unset to use the default." ); } - Arc::new(PgAclEngine::new(pool, folder_repo, file_repo)) + Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo)) } diff --git a/src/domain/entities/entity_errors.rs b/src/domain/entities/entity_errors.rs index f166a7fa..96368558 100644 --- a/src/domain/entities/entity_errors.rs +++ b/src/domain/entities/entity_errors.rs @@ -194,6 +194,52 @@ impl Error for CalendarEventError {} /// Type alias for CalendarEvent entity operation results pub type CalendarEventResult = Result; +// ============================================================================ +// SUBJECT GROUP ERRORS +// ============================================================================ + +/// Errors that can occur during SubjectGroup entity operations. +/// +/// Subject groups are ReBAC authorization principals: root-owned, named with +/// RFC 5321 local-part shape (so they may later be addressed as email locals), +/// and able to nest (a group can contain other groups, with cycle detection +/// at write time at the application layer). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubjectGroupError { + /// Name doesn't match the RFC 5321 local-part shape (alnum start, then + /// alnum/dot/dash/underscore, max 64 chars). + InvalidName(String), + /// Cycle detected — the proposed membership would create a loop in the + /// group-of-groups graph. + CycleDetected(String), + /// Adding this member would exceed the maximum nesting depth. + DepthExceeded(String), + /// Attempt to mutate (delete, rename, or change membership of) an + /// immutable virtual group such as `Internal`. + VirtualImmutable(String), + /// General validation error. + ValidationError(String), +} + +impl Display for SubjectGroupError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + SubjectGroupError::InvalidName(msg) => write!(f, "Invalid group name: {}", msg), + SubjectGroupError::CycleDetected(msg) => write!(f, "Cycle detected: {}", msg), + SubjectGroupError::DepthExceeded(msg) => write!(f, "Group depth exceeded: {}", msg), + SubjectGroupError::VirtualImmutable(msg) => { + write!(f, "Virtual group is immutable: {}", msg) + } + SubjectGroupError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + } + } +} + +impl Error for SubjectGroupError {} + +/// Type alias for SubjectGroup entity operation results. +pub type SubjectGroupResult = Result; + // ============================================================================ // TESTS // ============================================================================ @@ -254,5 +300,18 @@ mod tests { assert_error::(); assert_error::(); assert_error::(); + assert_error::(); + } + + #[test] + fn test_subject_group_error_display() { + let err = SubjectGroupError::InvalidName("Engineering Team".to_string()); + assert_eq!(err.to_string(), "Invalid group name: Engineering Team"); + + let err = SubjectGroupError::CycleDetected("qa → engineering → qa".to_string()); + assert_eq!(err.to_string(), "Cycle detected: qa → engineering → qa"); + + let err = SubjectGroupError::DepthExceeded("would reach depth 9".to_string()); + assert_eq!(err.to_string(), "Group depth exceeded: would reach depth 9"); } } diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index f59ddd2f..abfc2d6e 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -9,11 +9,13 @@ pub mod folder; pub mod playlist; pub mod session; pub mod share; +pub mod subject_group; pub mod trashed_item; pub mod user; // Re-exportar errores de entidades para facilitar el uso pub use entity_errors::{ CalendarError, CalendarEventError, CalendarEventResult, CalendarResult, FileError, FileResult, - FolderError, FolderResult, ShareError, ShareResult, UserError, UserResult, + FolderError, FolderResult, ShareError, ShareResult, SubjectGroupError, SubjectGroupResult, + UserError, UserResult, }; diff --git a/src/domain/entities/subject_group.rs b/src/domain/entities/subject_group.rs new file mode 100644 index 00000000..2ada7cf7 --- /dev/null +++ b/src/domain/entities/subject_group.rs @@ -0,0 +1,199 @@ +//! Subject group: ReBAC authorization principal. +//! +//! Subject groups are root-owned (no `owner_id`), globally named with an +//! RFC 5321 local-part shape, and able to contain users *or* other groups. +//! Grants in `storage.access_grants` with `subject_type = 'group'` reference +//! a row in `auth.subject_groups`. +//! +//! Cycle prevention and depth-cap (`MAX_GROUP_DEPTH`) are enforced at the +//! application layer at write time. The database schema enforces: +//! - case-insensitive uniqueness on `name` (CITEXT), +//! - the RFC 5321 local-part shape (CHECK regex), +//! - the XOR of (`member_user_id`, `member_group_id`) on memberships, +//! - no self-membership at the row level (a group can't list itself +//! directly as a child — longer cycles are application-layer concerns). +//! +//! See `migrations/20260612000000_subject_groups.sql`. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +pub use super::entity_errors::{SubjectGroupError, SubjectGroupResult}; + +/// Well-known UUID of the predefined `Internal` virtual group. +/// +/// Hard-coded so application code can reference it without a runtime +/// lookup. Membership is implicit: every authenticated user is treated as +/// belonging to this group at evaluation time (see +/// `PgAclEngine::expand_subject`). Once the external-users work lands, this +/// will narrow to "every user with `is_external = false`". +pub const INTERNAL_GROUP_ID: Uuid = + Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); + +/// Maximum allowed nesting depth for groups-of-groups. +/// +/// Enforced at write time inside `add_member`. The recursive CTE that +/// expands a user's transitive membership is bounded by this value, which +/// keeps authz checks predictable and prevents pathological graphs. +pub const MAX_GROUP_DEPTH: u8 = 8; + +/// Maximum length of an RFC 5321 local-part. +const MAX_NAME_LEN: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectGroup { + pub id: Uuid, + pub name: String, + pub description: Option, + pub is_virtual: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A member of a subject group: either an internal user or another group. +/// +/// Externals and tokens may be added later; today only users and groups can +/// be members (matching the schema's tagged-union row). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupMember { + User(Uuid), + Group(Uuid), +} + +impl SubjectGroup { + /// Construct a new group, validating the name shape. + /// + /// The DB CHECK constraint is the authority; this validation exists so + /// the service layer can return a typed error before the round-trip. + pub fn new(name: &str, description: Option) -> SubjectGroupResult { + Self::validate_name(name)?; + let now = Utc::now(); + Ok(Self { + id: Uuid::new_v4(), + name: name.to_string(), + description, + is_virtual: false, + created_at: now, + updated_at: now, + }) + } + + /// Validate the name against the RFC 5321 local-part shape used by the + /// DB CHECK constraint: `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`. + pub fn validate_name(name: &str) -> SubjectGroupResult<()> { + if name.is_empty() { + return Err(SubjectGroupError::InvalidName("empty".to_string())); + } + if name.len() > MAX_NAME_LEN { + return Err(SubjectGroupError::InvalidName(format!( + "exceeds {} chars", + MAX_NAME_LEN + ))); + } + + // First char: must be alphanumeric ASCII (RFC 5321 is ASCII-only). + let mut chars = name.chars(); + let first = chars.next().expect("non-empty above"); + if !first.is_ascii_alphanumeric() { + return Err(SubjectGroupError::InvalidName(format!( + "must start with letter or digit: {}", + name + ))); + } + + // Remaining: alphanumeric or one of `.`, `-`, `_`. + for c in chars { + if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { + return Err(SubjectGroupError::InvalidName(format!( + "invalid character {:?} in {}", + c, name + ))); + } + } + + Ok(()) + } + + /// Whether mutations on this group are forbidden by virtue of it being a + /// system-managed virtual group (e.g. `Internal`). + pub fn is_immutable(&self) -> bool { + self.is_virtual + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_simple_rfc5321_local_part() { + assert!(SubjectGroup::validate_name("engineering").is_ok()); + assert!(SubjectGroup::validate_name("eng-team_42").is_ok()); + assert!(SubjectGroup::validate_name("a.b.c").is_ok()); + assert!(SubjectGroup::validate_name("X").is_ok()); + } + + #[test] + fn rejects_empty_name() { + assert!(matches!( + SubjectGroup::validate_name(""), + Err(SubjectGroupError::InvalidName(_)) + )); + } + + #[test] + fn rejects_space() { + assert!(matches!( + SubjectGroup::validate_name("Engineering Team"), + Err(SubjectGroupError::InvalidName(_)) + )); + } + + #[test] + fn rejects_non_alnum_start() { + assert!(matches!( + SubjectGroup::validate_name(".dotfirst"), + Err(SubjectGroupError::InvalidName(_)) + )); + assert!(matches!( + SubjectGroup::validate_name("-dashfirst"), + Err(SubjectGroupError::InvalidName(_)) + )); + } + + #[test] + fn rejects_unicode() { + assert!(matches!( + SubjectGroup::validate_name("équipe"), + Err(SubjectGroupError::InvalidName(_)) + )); + assert!(matches!( + SubjectGroup::validate_name("group🚀"), + Err(SubjectGroupError::InvalidName(_)) + )); + } + + #[test] + fn rejects_too_long() { + let name = "a".repeat(65); + assert!(matches!( + SubjectGroup::validate_name(&name), + Err(SubjectGroupError::InvalidName(_)) + )); + } + + #[test] + fn accepts_exactly_64_chars() { + let name = "a".repeat(64); + assert!(SubjectGroup::validate_name(&name).is_ok()); + } + + #[test] + fn internal_group_id_is_stable() { + // Match the well-known UUID seeded by migration 20260612000000. + assert_eq!( + INTERNAL_GROUP_ID.to_string(), + "00000000-0000-0000-0000-000000000001" + ); + } +} diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs index 469a3724..de7e365b 100644 --- a/src/domain/repositories/mod.rs +++ b/src/domain/repositories/mod.rs @@ -8,5 +8,6 @@ pub mod playlist_repository; pub mod session_repository; pub mod settings_repository; pub mod share_repository; +pub mod subject_group_repository; pub mod trash_repository; pub mod user_repository; diff --git a/src/domain/repositories/subject_group_repository.rs b/src/domain/repositories/subject_group_repository.rs new file mode 100644 index 00000000..96212b07 --- /dev/null +++ b/src/domain/repositories/subject_group_repository.rs @@ -0,0 +1,140 @@ +//! Repository for ReBAC subject groups. +//! +//! See `src/domain/entities/subject_group.rs` for the entity and +//! `migrations/20260612000000_subject_groups.sql` for the schema. + +use std::collections::HashSet; + +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::entities::subject_group::{GroupMember, SubjectGroup}; + +#[derive(Debug, Error)] +pub enum SubjectGroupRepositoryError { + #[error("Group not found: {0}")] + NotFound(String), + #[error("Group with name already exists: {0}")] + NameAlreadyExists(String), + #[error("Member already in group")] + MemberAlreadyPresent, + #[error("Member not in group")] + MemberNotPresent, + /// Attempting to add a group-member that would create a cycle. + #[error("Adding this member would create a cycle: {0}")] + Cycle(String), + /// Attempting to add a group-member that would exceed `MAX_GROUP_DEPTH`. + #[error("Adding this member would exceed the maximum nesting depth: {0}")] + DepthExceeded(String), + /// Attempt to mutate the immutable `Internal` virtual group. + #[error("Virtual groups cannot be modified: {0}")] + VirtualImmutable(String), + /// Group name fails RFC 5321 local-part validation (mirrored at the DB + /// via a CHECK constraint). + #[error("Invalid group name: {0}")] + InvalidName(String), + #[error("Storage error: {0}")] + StorageError(String), +} + +pub trait SubjectGroupRepository: Send + Sync + 'static { + /// Create a new (non-virtual) group. Fails with `NameAlreadyExists` if + /// the name (case-insensitive) is taken, or `InvalidName` if the DB + /// CHECK rejects the shape. + async fn create( + &self, + group: &SubjectGroup, + ) -> Result; + + /// Fetch a group by primary key. Returns `None` if missing — callers + /// decide whether absence is an error. + async fn get_by_id( + &self, + id: Uuid, + ) -> Result, SubjectGroupRepositoryError>; + + /// Fetch a group by name. `CITEXT` makes this case-insensitive. + async fn get_by_name( + &self, + name: &str, + ) -> Result, SubjectGroupRepositoryError>; + + /// List groups; `name_query` is a substring match (ILIKE) when provided. + /// Returns `(rows, total)` for pagination UIs. + async fn list( + &self, + limit: u32, + offset: u32, + name_query: Option<&str>, + ) -> Result<(Vec, u64), SubjectGroupRepositoryError>; + + /// Same as `list`, but each row is paired with its direct-member count. + /// Used by the management UI to show "(N members)" on each row without + /// the N+1 query of calling `count_members` per row. The count is the + /// LEFT-JOIN aggregate from `auth.subject_group_members` so the + /// implementation pulls everything in a single SQL round-trip. + async fn list_with_counts( + &self, + limit: u32, + offset: u32, + name_query: Option<&str>, + ) -> Result<(Vec<(SubjectGroup, i64)>, u64), SubjectGroupRepositoryError>; + + /// Count direct members (users + nested groups) of a single group. + /// Used by single-item endpoints (create / get / update) so the response + /// DTO can include `member_count` without a separate frontend round-trip. + async fn count_members(&self, id: Uuid) -> Result; + + /// Rename the group. Fails on collision or invalid shape. + async fn rename( + &self, + id: Uuid, + new_name: &str, + ) -> Result; + + /// Delete the group. Cascades to `subject_group_members` and to + /// `storage.access_grants` rows referencing this group as subject (via + /// the application service — there is no FK between `access_grants` and + /// `subject_groups`, so the service performs the cascade explicitly in + /// the same transaction). + async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError>; + + /// Add a member (user or another group). Performs cycle + depth checks + /// inside the same transaction (`SELECT ... FOR UPDATE` on the parent + /// row to prevent racing concurrent adds from squeezing under the limit + /// individually). + async fn add_member( + &self, + group_id: Uuid, + member: GroupMember, + added_by: Uuid, + ) -> Result<(), SubjectGroupRepositoryError>; + + /// Remove a member. No-op-safe: returns `MemberNotPresent` if the row + /// doesn't exist. + async fn remove_member( + &self, + group_id: Uuid, + member: GroupMember, + ) -> Result<(), SubjectGroupRepositoryError>; + + /// Direct members of `group_id` (one level only, not transitive). + async fn list_direct_members( + &self, + group_id: Uuid, + ) -> Result, SubjectGroupRepositoryError>; + + /// All users transitively in `group_id` (debug / audit / admin views). + async fn list_transitive_users( + &self, + group_id: Uuid, + ) -> Result, SubjectGroupRepositoryError>; + + /// All groups `user_id` belongs to transitively. This is the hot path + /// driven by `PgAclEngine::expand_subject` on every cache miss — the + /// `Internal` virtual group is NOT included here (the engine adds it). + async fn groups_for_user( + &self, + user_id: Uuid, + ) -> Result, SubjectGroupRepositoryError>; +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 87ac46c6..34e535f1 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -14,6 +14,7 @@ mod recent_items_pg_repository; mod session_pg_repository; mod settings_pg_repository; mod share_pg_repository; +mod subject_group_pg_repository; mod transaction_utils; mod user_pg_repository; @@ -44,6 +45,7 @@ pub use recent_items_pg_repository::RecentItemsPgRepository; pub use session_pg_repository::SessionPgRepository; pub use settings_pg_repository::SettingsPgRepository; pub use share_pg_repository::SharePgRepository; +pub use subject_group_pg_repository::SubjectGroupPgRepository; pub use trash_db_repository::TrashDbRepository; pub use user_pg_repository::UserPgRepository; diff --git a/src/infrastructure/repositories/pg/subject_group_pg_repository.rs b/src/infrastructure/repositories/pg/subject_group_pg_repository.rs new file mode 100644 index 00000000..0063c495 --- /dev/null +++ b/src/infrastructure/repositories/pg/subject_group_pg_repository.rs @@ -0,0 +1,576 @@ +//! Postgres implementation of `SubjectGroupRepository`. +//! +//! Two queries are non-trivial and deserve a read pass: +//! - **Cycle check** (write-time, inside `add_member` when adding a +//! group-member): walks child-edges from the candidate; if the parent +//! appears in the descendants, reject. +//! - **Transitive expansion** (`groups_for_user`): hot path on every +//! authz cache miss; walks parent-edges from the user's direct +//! memberships upward through nested groups. +//! +//! Depth-cap (`MAX_GROUP_DEPTH = 8`) is enforced at write time inside the +//! same transaction as the membership insert. +//! +//! See `migrations/20260612000000_subject_groups.sql` for the schema. + +use std::collections::HashSet; +use std::sync::Arc; + +use sqlx::{PgPool, Row, types::Uuid}; + +use super::like_escape; +use crate::domain::entities::subject_group::{GroupMember, MAX_GROUP_DEPTH, SubjectGroup}; +use crate::domain::repositories::subject_group_repository::{ + SubjectGroupRepository, SubjectGroupRepositoryError, +}; + +pub struct SubjectGroupPgRepository { + pool: Arc, +} + +impl SubjectGroupPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> SubjectGroupRepositoryError { + // Recognise common Postgres errors and translate to typed variants. + if let sqlx::Error::Database(ref dberr) = e + && let Some(code) = dberr.code() + { + match code.as_ref() { + // unique_violation — name collision (or duplicate member, but + // the caller already handles that case via UNIQUE indexes + // returning the same code). + "23505" => { + return SubjectGroupRepositoryError::NameAlreadyExists(dberr.to_string()); + } + // check_violation — RFC 5321 regex CHECK failed. + "23514" => return SubjectGroupRepositoryError::InvalidName(dberr.to_string()), + _ => {} + } + } + SubjectGroupRepositoryError::StorageError(format!("{}: {}", context, e)) + } + + fn row_to_group(row: &sqlx::postgres::PgRow) -> SubjectGroup { + SubjectGroup { + id: row.get::("id"), + name: row.get::("name"), + description: row.get::, _>("description"), + is_virtual: row.get::("is_virtual"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + } +} + +impl SubjectGroupRepository for SubjectGroupPgRepository { + async fn create( + &self, + group: &SubjectGroup, + ) -> Result { + let row = sqlx::query( + "INSERT INTO auth.subject_groups (id, name, description, is_virtual, created_at, updated_at) + VALUES ($1, $2, $3, false, $4, $5) + RETURNING id, name, description, is_virtual, created_at, updated_at", + ) + .bind(group.id) + .bind(&group.name) + .bind(&group.description) + .bind(group.created_at) + .bind(group.updated_at) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("create subject_group", e))?; + + Ok(Self::row_to_group(&row)) + } + + async fn get_by_id( + &self, + id: Uuid, + ) -> Result, SubjectGroupRepositoryError> { + let row = sqlx::query( + "SELECT id, name, description, is_virtual, created_at, updated_at + FROM auth.subject_groups WHERE id = $1", + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("get_by_id", e))?; + + Ok(row.as_ref().map(Self::row_to_group)) + } + + async fn get_by_name( + &self, + name: &str, + ) -> Result, SubjectGroupRepositoryError> { + // CITEXT matches case-insensitively — no need for LOWER() here. + let row = sqlx::query( + "SELECT id, name, description, is_virtual, created_at, updated_at + FROM auth.subject_groups WHERE name = $1", + ) + .bind(name) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("get_by_name", e))?; + + Ok(row.as_ref().map(Self::row_to_group)) + } + + async fn list( + &self, + limit: u32, + offset: u32, + name_query: Option<&str>, + ) -> Result<(Vec, u64), SubjectGroupRepositoryError> { + // Two queries: one for the page, one for the total count. The query + // is small and frequent; a window function would add complexity for + // no measurable win. + let (sql_page, sql_count, pattern) = match name_query { + Some(q) => { + let pat = like_escape(q); + ( + "SELECT id, name, description, is_virtual, created_at, updated_at + FROM auth.subject_groups + WHERE name ILIKE $1 + ORDER BY is_virtual DESC, name + LIMIT $2 OFFSET $3" + .to_string(), + "SELECT COUNT(*) FROM auth.subject_groups WHERE name ILIKE $1".to_string(), + Some(pat), + ) + } + None => ( + "SELECT id, name, description, is_virtual, created_at, updated_at + FROM auth.subject_groups + ORDER BY is_virtual DESC, name + LIMIT $1 OFFSET $2" + .to_string(), + "SELECT COUNT(*) FROM auth.subject_groups".to_string(), + None, + ), + }; + + let rows = if let Some(ref p) = pattern { + sqlx::query(&sql_page) + .bind(p) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query(&sql_page) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| Self::map_sqlx_err("list page", e))?; + + let total: i64 = if let Some(ref p) = pattern { + sqlx::query_scalar(&sql_count) + .bind(p) + .fetch_one(self.pool.as_ref()) + .await + } else { + sqlx::query_scalar(&sql_count) + .fetch_one(self.pool.as_ref()) + .await + } + .map_err(|e| Self::map_sqlx_err("list count", e))?; + + Ok((rows.iter().map(Self::row_to_group).collect(), total as u64)) + } + + async fn list_with_counts( + &self, + limit: u32, + offset: u32, + name_query: Option<&str>, + ) -> Result<(Vec<(SubjectGroup, i64)>, u64), SubjectGroupRepositoryError> { + // Single SQL: groups + COUNT of direct members per group, via LEFT JOIN + // on `auth.subject_group_members`. No N+1; one round-trip for the + // page, a second for the unfiltered total (matches `list`). + let (sql_page, sql_count, pattern) = match name_query { + Some(q) => { + let pat = like_escape(q); + ( + "SELECT g.id, g.name, g.description, g.is_virtual, + g.created_at, g.updated_at, + COUNT(m.group_id) AS member_count + FROM auth.subject_groups g + LEFT JOIN auth.subject_group_members m ON m.group_id = g.id + WHERE g.name ILIKE $1 + GROUP BY g.id + ORDER BY g.is_virtual DESC, g.name + LIMIT $2 OFFSET $3" + .to_string(), + "SELECT COUNT(*) FROM auth.subject_groups WHERE name ILIKE $1".to_string(), + Some(pat), + ) + } + None => ( + "SELECT g.id, g.name, g.description, g.is_virtual, + g.created_at, g.updated_at, + COUNT(m.group_id) AS member_count + FROM auth.subject_groups g + LEFT JOIN auth.subject_group_members m ON m.group_id = g.id + GROUP BY g.id + ORDER BY g.is_virtual DESC, g.name + LIMIT $1 OFFSET $2" + .to_string(), + "SELECT COUNT(*) FROM auth.subject_groups".to_string(), + None, + ), + }; + + let rows = if let Some(ref p) = pattern { + sqlx::query(&sql_page) + .bind(p) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query(&sql_page) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| Self::map_sqlx_err("list_with_counts page", e))?; + + let total: i64 = if let Some(ref p) = pattern { + sqlx::query_scalar(&sql_count) + .bind(p) + .fetch_one(self.pool.as_ref()) + .await + } else { + sqlx::query_scalar(&sql_count) + .fetch_one(self.pool.as_ref()) + .await + } + .map_err(|e| Self::map_sqlx_err("list_with_counts total", e))?; + + let items = rows + .iter() + .map(|r| (Self::row_to_group(r), r.get::("member_count"))) + .collect(); + + Ok((items, total as u64)) + } + + async fn count_members(&self, id: Uuid) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM auth.subject_group_members WHERE group_id = $1", + ) + .bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("count_members", e))?; + Ok(count) + } + + async fn rename( + &self, + id: Uuid, + new_name: &str, + ) -> Result { + let row = sqlx::query( + "UPDATE auth.subject_groups + SET name = $2, updated_at = now() + WHERE id = $1 + RETURNING id, name, description, is_virtual, created_at, updated_at", + ) + .bind(id) + .bind(new_name) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("rename", e))?; + + match row { + Some(r) => Ok(Self::row_to_group(&r)), + None => Err(SubjectGroupRepositoryError::NotFound(id.to_string())), + } + } + + async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError> { + // The application service is responsible for clearing related + // `storage.access_grants` rows in the same transaction (there's no + // FK between access_grants and subject_groups). The subject_group_members + // rows cascade automatically via FK. + let result = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("delete", e))?; + + if result.rows_affected() == 0 { + return Err(SubjectGroupRepositoryError::NotFound(id.to_string())); + } + Ok(()) + } + + async fn add_member( + &self, + group_id: Uuid, + member: GroupMember, + added_by: Uuid, + ) -> Result<(), SubjectGroupRepositoryError> { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| Self::map_sqlx_err("add_member: begin tx", e))?; + + // Lock the parent row to prevent racing concurrent adds from each + // squeezing under the cycle/depth limits. + let exists: Option<(Uuid,)> = + sqlx::query_as("SELECT id FROM auth.subject_groups WHERE id = $1 FOR UPDATE") + .bind(group_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("add_member: lock parent", e))?; + if exists.is_none() { + return Err(SubjectGroupRepositoryError::NotFound(group_id.to_string())); + } + + match member { + GroupMember::User(user_id) => { + // Plain insert. Unique index catches duplicates. + let res = sqlx::query( + "INSERT INTO auth.subject_group_members + (group_id, member_user_id, added_by) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING", + ) + .bind(group_id) + .bind(user_id) + .bind(added_by) + .execute(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("add_member: insert user", e))?; + + if res.rows_affected() == 0 { + return Err(SubjectGroupRepositoryError::MemberAlreadyPresent); + } + } + GroupMember::Group(member_group_id) => { + if member_group_id == group_id { + return Err(SubjectGroupRepositoryError::Cycle( + "group cannot contain itself".to_string(), + )); + } + + // ── Cycle check ───────────────────────────────────────── + // Adding member_group_id=$child to group_id=$parent creates + // a cycle iff $parent is reachable by walking child-edges + // from $child. Use a bounded recursion (UNION de-dups). + let cycle: Option<(i32,)> = sqlx::query_as( + "WITH RECURSIVE descendants AS ( + SELECT member_group_id AS g + FROM auth.subject_group_members + WHERE group_id = $1 AND member_group_id IS NOT NULL + UNION + SELECT m.member_group_id + FROM auth.subject_group_members m + JOIN descendants d ON m.group_id = d.g + WHERE m.member_group_id IS NOT NULL + ) + SELECT 1 FROM descendants WHERE g = $2 LIMIT 1", + ) + .bind(member_group_id) + .bind(group_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("add_member: cycle check", e))?; + if cycle.is_some() { + return Err(SubjectGroupRepositoryError::Cycle(format!( + "{} → {}", + group_id, member_group_id + ))); + } + + // ── Depth check ───────────────────────────────────────── + // The longest path from $parent after the mutation = + // max(longest path from existing descendants, 1 + longest + // path under $child). Compute both with the same CTE, + // pretending the new edge already exists. + let depth: Option<(i32,)> = sqlx::query_as( + "WITH RECURSIVE path AS ( + -- existing depth from this group downward + SELECT member_group_id AS g, 1 AS depth + FROM auth.subject_group_members + WHERE group_id = $1 AND member_group_id IS NOT NULL + UNION ALL + -- proposed new edge + SELECT $2::uuid AS g, 1 AS depth + UNION ALL + SELECT m.member_group_id, p.depth + 1 + FROM auth.subject_group_members m + JOIN path p ON m.group_id = p.g + WHERE m.member_group_id IS NOT NULL + ) + SELECT MAX(depth) FROM path", + ) + .bind(group_id) + .bind(member_group_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("add_member: depth check", e))?; + + let max_depth = depth.map(|d| d.0).unwrap_or(0); + if (max_depth as u8) > MAX_GROUP_DEPTH { + return Err(SubjectGroupRepositoryError::DepthExceeded(format!( + "would reach depth {} (max {})", + max_depth, MAX_GROUP_DEPTH + ))); + } + + // ── Insert ────────────────────────────────────────────── + let res = sqlx::query( + "INSERT INTO auth.subject_group_members + (group_id, member_group_id, added_by) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING", + ) + .bind(group_id) + .bind(member_group_id) + .bind(added_by) + .execute(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("add_member: insert group", e))?; + + if res.rows_affected() == 0 { + return Err(SubjectGroupRepositoryError::MemberAlreadyPresent); + } + } + } + + tx.commit() + .await + .map_err(|e| Self::map_sqlx_err("add_member: commit", e))?; + Ok(()) + } + + async fn remove_member( + &self, + group_id: Uuid, + member: GroupMember, + ) -> Result<(), SubjectGroupRepositoryError> { + let res = match member { + GroupMember::User(uid) => sqlx::query( + "DELETE FROM auth.subject_group_members + WHERE group_id = $1 AND member_user_id = $2", + ) + .bind(group_id) + .bind(uid), + GroupMember::Group(gid) => sqlx::query( + "DELETE FROM auth.subject_group_members + WHERE group_id = $1 AND member_group_id = $2", + ) + .bind(group_id) + .bind(gid), + } + .execute(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("remove_member", e))?; + + if res.rows_affected() == 0 { + return Err(SubjectGroupRepositoryError::MemberNotPresent); + } + Ok(()) + } + + async fn list_direct_members( + &self, + group_id: Uuid, + ) -> Result, SubjectGroupRepositoryError> { + let rows = sqlx::query( + "SELECT member_user_id, member_group_id + FROM auth.subject_group_members + WHERE group_id = $1", + ) + .bind(group_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("list_direct_members", e))?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let user_id: Option = row.get("member_user_id"); + let group_id: Option = row.get("member_group_id"); + match (user_id, group_id) { + (Some(uid), None) => out.push(GroupMember::User(uid)), + (None, Some(gid)) => out.push(GroupMember::Group(gid)), + _ => { + // XOR check at the schema level guarantees we never hit + // this branch — log defensively if we do. + tracing::warn!( + "subject_group_members row violates XOR invariant (user={:?}, group={:?})", + user_id, + group_id + ); + } + } + } + Ok(out) + } + + async fn list_transitive_users( + &self, + group_id: Uuid, + ) -> Result, SubjectGroupRepositoryError> { + // Walk child-edges from `group_id` to find every user transitively + // a member. Used by debug / audit endpoints. + let rows = sqlx::query( + "WITH RECURSIVE descendants AS ( + SELECT $1::uuid AS g + UNION + SELECT m.member_group_id + FROM auth.subject_group_members m + JOIN descendants d ON m.group_id = d.g + WHERE m.member_group_id IS NOT NULL + ) + SELECT DISTINCT m.member_user_id AS user_id + FROM auth.subject_group_members m + JOIN descendants d ON m.group_id = d.g + WHERE m.member_user_id IS NOT NULL", + ) + .bind(group_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("list_transitive_users", e))?; + + Ok(rows.iter().map(|r| r.get::("user_id")).collect()) + } + + async fn groups_for_user( + &self, + user_id: Uuid, + ) -> Result, SubjectGroupRepositoryError> { + // The hot path. PgAclEngine::expand_subject calls this on every + // cache miss; result is memoised in the Moka cache for ~30s. + let rows = sqlx::query( + "WITH RECURSIVE user_groups AS ( + SELECT group_id + FROM auth.subject_group_members + WHERE member_user_id = $1 + UNION + SELECT m.group_id + FROM auth.subject_group_members m + JOIN user_groups ug ON m.member_group_id = ug.group_id + ) + SELECT group_id FROM user_groups", + ) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("groups_for_user", e))?; + + Ok(rows.iter().map(|r| r.get::("group_id")).collect()) + } +} diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index e4a6898c..f36ce33a 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -28,24 +28,49 @@ //! DB transaction with the resource table need an explicit signal to //! delete their tuples. +use std::collections::HashSet; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; use uuid::Uuid; +use moka::future::Cache; use sqlx::PgPool; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; +use crate::domain::entities::subject_group::INTERNAL_GROUP_ID; +use crate::domain::repositories::subject_group_repository::SubjectGroupRepository; use crate::domain::services::authorization::{ Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary, Permission, Resource, ResourceKind, Subject, }; +use crate::infrastructure::repositories::pg::SubjectGroupPgRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; +/// Per-call counters surfaced through `tracing::debug!` for performance +/// observability: cache hit-rate, SQL traffic, transitive expansion size. +/// +/// Sub-microsecond cost when debug logging is off (one atomic write per +/// increment, no allocation, no formatting). +#[derive(Default)] +struct QueryCounters { + cache_hit: AtomicU32, + sql_queries: AtomicU32, + expanded_groups: AtomicU32, +} + pub struct PgAclEngine { pool: Arc, folder_repo: Arc, file_repo: Arc, + /// Group repository — `None` only in test stubs that don't exercise authz. + group_repo: Option>, + /// Memoise `user_id → transitive group set` for 30 s. Bounded to 50 000 + /// entries; eviction is LRU + TTL. Stale by up to TTL after a membership + /// change — acceptable trade-off (see plan, "Cache TTL behaviour"). + user_groups_cache: Cache>>, } impl PgAclEngine { @@ -53,11 +78,17 @@ impl PgAclEngine { pool: Arc, folder_repo: Arc, file_repo: Arc, + group_repo: Arc, ) -> Self { Self { pool, folder_repo, file_repo, + group_repo: Some(group_repo), + user_groups_cache: Cache::builder() + .max_capacity(50_000) + .time_to_live(Duration::from_secs(30)) + .build(), } } @@ -75,6 +106,79 @@ impl PgAclEngine { pool: Arc::new(pool), folder_repo: Arc::new(FolderDbRepository::new_stub()), file_repo: Arc::new(FileBlobReadRepository::new_stub()), + group_repo: None, + user_groups_cache: Cache::builder() + .max_capacity(1) + .time_to_live(Duration::from_secs(1)) + .build(), + } + } + + /// Expand a user subject into the set of subject UUIDs that should match + /// in `access_grants`: the user's own UUID, every group the user is + /// transitively a member of, and the implicit `INTERNAL_GROUP_ID`. + /// + /// This is the **only** place transitive membership is walked. A future + /// closure-table swap-in (Option 3 in the design doc) replaces just the + /// `repo.groups_for_user` call below — every caller stays unchanged. + async fn expand_user( + &self, + user_id: Uuid, + counters: &QueryCounters, + ) -> Result>, DomainError> { + if let Some(cached) = self.user_groups_cache.get(&user_id).await { + counters.cache_hit.store(1, Ordering::Relaxed); + counters + .expanded_groups + .store(cached.len() as u32, Ordering::Relaxed); + return Ok(cached); + } + + let mut set: HashSet = HashSet::new(); + set.insert(user_id); + // The Internal virtual group: implicit membership for every + // authenticated user. Once the external-users work lands this will + // narrow to `if !user.is_external { ... }`. + set.insert(INTERNAL_GROUP_ID); + + if let Some(repo) = &self.group_repo { + counters.sql_queries.fetch_add(1, Ordering::Relaxed); + let direct = repo.groups_for_user(user_id).await.map_err(|e| { + DomainError::internal_error("PgAcl", format!("groups_for_user: {e}")) + })?; + set.extend(direct); + } + + counters + .expanded_groups + .store(set.len() as u32, Ordering::Relaxed); + let arc = Arc::new(set); + self.user_groups_cache.insert(user_id, arc.clone()).await; + Ok(arc) + } + + /// Expand a caller's `Subject` into the `(subject_types, subject_ids)` + /// pair that should be matched in `storage.access_grants`. For User + /// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`; + /// for any non-user subject (Token / External / Group as direct caller) + /// it's a single-element pair with no cascade. + /// + /// Shared by `check_inner` (permission decision) and the + /// `list_incoming_*` queries ("Shared with me") so that any folder/file + /// the user can `read` via a group grant also appears in their incoming + /// listing. Shares the `expand_user` Moka cache, so the listing call + /// right after a permission check is a cache hit. + async fn subject_match_set( + &self, + subject: Subject, + counters: &QueryCounters, + ) -> Result<(Vec<&'static str>, Vec), DomainError> { + match subject { + Subject::User(uid) => { + let expanded = self.expand_user(uid, counters).await?; + Ok((vec!["user", "group"], expanded.iter().copied().collect())) + } + _ => Ok((vec![subject.type_str()], vec![subject.id()])), } } @@ -87,21 +191,32 @@ impl PgAclEngine { } /// Cascading check for folders: is there a grant on any ancestor folder - /// (including the target itself) in this subject + permission? - /// Uses GiST index on `storage.folders.lpath`. + /// (including the target itself) for any of the given subject IDs and + /// any of the given subject types? + /// + /// `subject_types` is `["user", "group"]` when the caller is a User + /// (so we match both their own grants and their group-mediated grants), + /// or a single-element slice for Token / External / Group-direct callers. + /// `subject_ids` is the expanded set returned by `expand_user` (or a + /// single-element vec for non-user callers). + /// + /// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade. async fn folder_cascade_grant_exists( &self, - subject: Subject, + subject_types: &[&str], + subject_ids: &[Uuid], permission: Permission, folder_id: Uuid, + counters: &QueryCounters, ) -> Result { + counters.sql_queries.fetch_add(1, Ordering::Relaxed); let exists: Option = sqlx::query_scalar( r#" SELECT 1 FROM storage.access_grants g JOIN storage.folders gf ON gf.id = g.resource_id - WHERE g.subject_type = $1 - AND g.subject_id = $2 + WHERE g.subject_type = ANY($1) + AND g.subject_id = ANY($2) AND g.permission = $3 AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) @@ -109,8 +224,8 @@ impl PgAclEngine { LIMIT 1 "#, ) - .bind(subject.type_str()) - .bind(subject.id()) + .bind(subject_types) + .bind(subject_ids) .bind(permission.as_str()) .bind(folder_id) .fetch_optional(self.pool.as_ref()) @@ -121,13 +236,18 @@ impl PgAclEngine { } /// Cascading check for files: either a direct file grant OR a grant on - /// any ancestor folder of the file's containing folder. + /// any ancestor folder of the file's containing folder. See + /// `folder_cascade_grant_exists` for the meaning of `subject_types` / + /// `subject_ids`. async fn file_cascade_grant_exists( &self, - subject: Subject, + subject_types: &[&str], + subject_ids: &[Uuid], permission: Permission, file_id: Uuid, + counters: &QueryCounters, ) -> Result { + counters.sql_queries.fetch_add(1, Ordering::Relaxed); let exists: Option = sqlx::query_scalar( r#" SELECT 1 @@ -135,7 +255,9 @@ impl PgAclEngine { -- direct file grant SELECT 1 FROM storage.access_grants - WHERE subject_type = $1 AND subject_id = $2 AND permission = $3 + WHERE subject_type = ANY($1) + AND subject_id = ANY($2) + AND permission = $3 AND resource_type = 'file' AND resource_id = $4 AND (expires_at IS NULL OR expires_at > NOW()) UNION ALL @@ -144,8 +266,8 @@ impl PgAclEngine { FROM storage.access_grants g JOIN storage.folders gf ON gf.id = g.resource_id JOIN storage.files target_f ON target_f.id = $4 - WHERE g.subject_type = $1 - AND g.subject_id = $2 + WHERE g.subject_type = ANY($1) + AND g.subject_id = ANY($2) AND g.permission = $3 AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) @@ -156,8 +278,8 @@ impl PgAclEngine { LIMIT 1 "#, ) - .bind(subject.type_str()) - .bind(subject.id()) + .bind(subject_types) + .bind(subject_ids) .bind(permission.as_str()) .bind(file_id) .fetch_optional(self.pool.as_ref()) @@ -222,18 +344,20 @@ impl PgAclEngine { expires_at: row.8, }) } -} -impl AuthorizationEngine for PgAclEngine { - async fn check( + /// The actual permission decision. Wrapped by `check()` which adds + /// per-call instrumentation. + async fn check_inner( &self, subject: Subject, permission: Permission, resource: Resource, + counters: &QueryCounters, ) -> Result { // Owner short-circuit (only for User subjects — groups/tokens/external // are never owners of resources). if let Subject::User(uid) = subject { + counters.sql_queries.fetch_add(1, Ordering::Relaxed); match self.owner_of(resource).await { Ok(owner) if owner == uid => return Ok(true), Ok(_) => { /* not owner — fall through to grants */ } @@ -247,18 +371,67 @@ impl AuthorizationEngine for PgAclEngine { } } - // Cascading grant check. + // Expand the subject so group-mediated grants apply when the caller + // is a User. See `subject_match_set` for the shared shape used by + // both the cascade check and the "shared with me" listing queries. + let (subject_types, subject_ids) = self.subject_match_set(subject, counters).await?; + match resource { Resource::Folder(id) => { - self.folder_cascade_grant_exists(subject, permission, id) - .await + self.folder_cascade_grant_exists( + &subject_types, + &subject_ids, + permission, + id, + counters, + ) + .await } Resource::File(id) => { - self.file_cascade_grant_exists(subject, permission, id) - .await + self.file_cascade_grant_exists( + &subject_types, + &subject_ids, + permission, + id, + counters, + ) + .await } } } +} + +impl AuthorizationEngine for PgAclEngine { + async fn check( + &self, + subject: Subject, + permission: Permission, + resource: Resource, + ) -> Result { + let start = std::time::Instant::now(); + let counters = QueryCounters::default(); + + let result = self + .check_inner(subject, permission, resource, &counters) + .await; + + // Single structured debug line per check. No-op when subscriber + // filter is at INFO or above. See plan, "Debug instrumentation". + tracing::debug!( + target: "oxicloud::authz", + event = "authz.check", + subject = %subject, + permission = %permission, + resource = %resource, + allowed = result.as_ref().copied().unwrap_or(false), + duration_us = start.elapsed().as_micros() as u64, + cache_hit = counters.cache_hit.load(Ordering::Relaxed) > 0, + sql_queries = counters.sql_queries.load(Ordering::Relaxed), + expanded_groups = counters.expanded_groups.load(Ordering::Relaxed), + ); + + result + } async fn list_incoming_grants( &self, @@ -266,6 +439,8 @@ impl AuthorizationEngine for PgAclEngine { permission_filter: Option, ) -> Result, DomainError> { let perm_str = permission_filter.map(|p| p.as_str().to_string()); + let counters = QueryCounters::default(); + let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?; let rows = sqlx::query_as::< _, @@ -285,14 +460,14 @@ impl AuthorizationEngine for PgAclEngine { SELECT id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at FROM storage.access_grants - WHERE subject_type = $1 - AND subject_id = $2 + WHERE subject_type = ANY($1) + AND subject_id = ANY($2) AND ($3::text IS NULL OR permission = $3) ORDER BY granted_at DESC "#, ) - .bind(subject.type_str()) - .bind(subject.id()) + .bind(&subject_types) + .bind(&subject_ids) .bind(perm_str) .fetch_all(self.pool.as_ref()) .await @@ -350,6 +525,10 @@ impl AuthorizationEngine for PgAclEngine { let cursor_id = cursor.as_ref().map(|c| c.resource_id); // ── agg CTE (identical in all branches) ─────────────────────────────── + // `subject_type`/`subject_id` are arrays here: for a User caller this + // is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the + // listing includes every resource the user can reach via a group + // grant (matching what `check()` allows). See `subject_match_set`. const AGG: &str = r#"agg AS ( SELECT resource_type, @@ -358,8 +537,8 @@ impl AuthorizationEngine for PgAclEngine { MIN(granted_at) AS granted_at, (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by FROM storage.access_grants - WHERE subject_type = $1 - AND subject_id = $2 + WHERE subject_type = ANY($1) + AND subject_id = ANY($2) AND ($3::text[] IS NULL OR resource_type = ANY($3)) GROUP BY resource_type, resource_id )"#; @@ -501,10 +680,15 @@ impl AuthorizationEngine for PgAclEngine { } }; + // Expand the caller so group-mediated grants surface in the listing, + // mirroring `check()`. Shares the Moka cache (`expand_user`). + let counters = QueryCounters::default(); + let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?; + // ── Execute — uniform 8 binds for every sort mode ───────────────────── let mut rows: Vec = sqlx::query_as::<_, Row>(&sql) - .bind(subject.type_str()) // $1 - .bind(subject.id()) // $2 + .bind(&subject_types) // $1 + .bind(&subject_ids) // $2 .bind(&kind_strs) // $3 .bind(&cursor_str) // $4 sort_str cursor .bind(cursor_int) // $5 sort_int cursor @@ -740,7 +924,7 @@ impl AuthorizationEngine for PgAclEngine { ) SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, ag.subject_type, ag.subject_id, - COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, + COALESCE(u.username, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, rp.sort_str, rp.sort_int, (sh.password_hash IS NOT NULL) AS has_password @@ -749,17 +933,34 @@ impl AuthorizationEngine for PgAclEngine { ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.granted_by = $1 LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN auth.subject_groups sg ON ag.subject_type = 'group' AND sg.id = ag.subject_id LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id - ORDER BY {page_order}, ag.subject_id, ag.granted_at"# + -- Per-resource grant ordering: groups → users → password-protected + -- links → public links (matches the "Shared with" subject sort). + -- Resource ordering comes from {page_order}; the CASE only + -- breaks ties within one resource. + ORDER BY {page_order}, + CASE + WHEN ag.subject_type = 'group' THEN 0 + WHEN ag.subject_type = 'user' THEN 1 + WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2 + ELSE 3 + END ASC, + LOWER(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) ASC, + ag.granted_at"# ) } "subject" => { // Page on (subject_type_order, subject_display, resource_id) triples so // every swimlane is always contiguous across cursor pages. // - // subject_type_order: 0 = user, 1 = token without password, 2 = token with password + // subject_type_order: 0 = group, 1 = user, 2 = token with password, + // 3 = token without password + // — picked so the My Shares "Shared with" view naturally renders the + // higher-trust principals (groups, then named users) above the + // lower-trust ones (anonymous link tokens). // // Cursor encodes: sort_int = subject_type_order, resource_name = LOWER(subject_display), // resource_id = last resource_id. @@ -787,17 +988,20 @@ impl AuthorizationEngine for PgAclEngine { ag.resource_id, ag.subject_type, ag.subject_id, - MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display, + MAX(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display, BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, MAX(CASE - WHEN ag.subject_type = 'user' THEN 0 - WHEN ag.subject_type = 'token' AND sh.password_hash IS NULL THEN 1 - ELSE 2 + WHEN ag.subject_type = 'group' THEN 0 + WHEN ag.subject_type = 'user' THEN 1 + WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2 + ELSE 3 END)::bigint AS sort_int, MIN(ag.granted_at) AS first_granted_at FROM storage.access_grants ag LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN auth.subject_groups sg + ON ag.subject_type = 'group' AND sg.id = ag.subject_id LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id LEFT JOIN storage.files fi diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index a4d4c3d7..aa397252 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -1,7 +1,7 @@ use axum::{ Router, extract::{Json, Path, Query, State}, - http::{HeaderMap, StatusCode, header}, + http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{delete, get, post, put}, }; @@ -12,9 +12,9 @@ use crate::application::dtos::settings_dto::{ TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto, }; -use crate::application::ports::auth_ports::TokenServicePort; use crate::common::di::AppState; use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::admin::require_admin; use std::sync::Arc; use uuid::Uuid; @@ -61,42 +61,12 @@ pub fn admin_routes() -> Router> { } /// Validate JWT and require admin role. Returns (user_id, role). +/// +/// Thin wrapper over the shared `require_admin` middleware helper so this +/// handler keeps a stable signature while the implementation lives next to +/// the new `subject_group_handler` that also needs it. async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> { - let auth = state - .auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string())) - .or_else(|| { - crate::interfaces::api::cookie_auth::extract_cookie_value( - headers, - crate::interfaces::api::cookie_auth::ACCESS_COOKIE, - ) - }) - .ok_or_else(|| AppError::unauthorized("Authorization token required"))?; - - let claims = auth - .token_service - .validate_token(&token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - if claims.role != "admin" { - return Err(AppError::new( - StatusCode::FORBIDDEN, - "Admin access required", - "Forbidden", - )); - } - - Ok(( - Uuid::parse_str(&claims.sub) - .map_err(|_| AppError::internal_error("Invalid user ID in token"))?, - claims.role, - )) + require_admin(state, headers).await } /// GET /api/admin/settings/oidc — get OIDC settings for the admin panel diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index caa75884..d41a37e8 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -18,6 +18,7 @@ pub mod photos_handler; pub mod recent_handler; pub mod search_handler; pub mod share_handler; +pub mod subject_group_handler; pub mod trash_handler; pub mod webdav_handler; pub mod wopi_handler; diff --git a/src/interfaces/api/handlers/subject_group_handler.rs b/src/interfaces/api/handlers/subject_group_handler.rs new file mode 100644 index 00000000..af1bbb9d --- /dev/null +++ b/src/interfaces/api/handlers/subject_group_handler.rs @@ -0,0 +1,550 @@ +//! REST handler for ReBAC subject groups (`/api/groups/...`). +//! +//! All mutating endpoints require admin role (see +//! `crate::interfaces::middleware::admin::require_admin`). The read-only +//! `/api/groups/search` endpoint requires only authentication so the share +//! dialog can offer groups as recipients. + +use std::sync::Arc; + +use axum::{ + Router, + extract::{Json, Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{delete, get, patch, post}, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::common::di::AppState; +use crate::domain::entities::subject_group::{GroupMember, SubjectGroup}; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::admin::{require_admin, require_authenticated}; + +// ── DTOs ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateGroupRequest { + /// RFC 5321 local-part shape (starts alnum, then alnum/dot/dash/underscore; 1–64 chars). + pub name: String, + pub description: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateGroupRequest { + pub name: Option, + /// Reserved for future use; v1 only persists the name in `rename`. + pub description: Option, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ListGroupsQuery { + #[serde(default = "default_limit")] + pub limit: u32, + #[serde(default)] + pub offset: u32, + /// Optional case-insensitive substring filter on group name. + pub q: Option, +} + +fn default_limit() -> u32 { + 50 +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct SearchGroupsQuery { + pub q: String, + #[serde(default = "default_search_limit")] + pub limit: u32, +} + +fn default_search_limit() -> u32 { + 20 +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct AddSubjectGroupMemberRequest { + /// Set exactly one of `user_id` or `group_id` — the other field must be + /// absent or null. Adding a `group_id` triggers a write-time cycle and + /// depth check; max nesting depth is 8. + pub user_id: Option, + pub group_id: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct GroupDto { + pub id: Uuid, + pub name: String, + pub description: Option, + /// True for system-managed groups (e.g. `Internal`). Membership and + /// metadata on virtual groups are immutable. + pub is_virtual: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + /// True when the caller may rename, delete, or change the membership + /// of this group. v1 computes this as `caller.role == "admin"`. v2 + /// will compute it from per-group `Manage` grants on + /// `Resource::SubjectGroup(id)` once that resource type lands in + /// `access_grants`. Frontend reads this unconditionally so the v2 + /// migration is backend-only. + pub can_manage: bool, + /// Direct-member count (users + nested groups, one level only). The + /// management UI shows this as a chip on each list row; for transitive + /// expansion size, see the `/effective-members` endpoint. + pub member_count: i64, +} + +impl GroupDto { + /// Build the DTO for a given caller. `can_manage` is decided per call: + /// v1 just delegates to the admin flag; v2 will consult the + /// authorization engine here. `member_count` comes from the same query + /// that fetched the group (list/search) or a dedicated `COUNT(*)` + /// helper (create/get/update). + pub fn from_group(g: SubjectGroup, can_manage: bool, member_count: i64) -> Self { + Self { + id: g.id, + name: g.name, + description: g.description, + is_virtual: g.is_virtual, + created_at: g.created_at, + updated_at: g.updated_at, + can_manage, + member_count, + } + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct GroupListDto { + pub items: Vec, + pub total: u64, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum GroupMemberDto { + User { id: Uuid }, + Group { id: Uuid }, +} + +impl From for GroupMemberDto { + fn from(m: GroupMember) -> Self { + match m { + GroupMember::User(id) => GroupMemberDto::User { id }, + GroupMember::Group(id) => GroupMemberDto::Group { id }, + } + } +} + +// ── Routes ─────────────────────────────────────────────────────────────────── + +/// Routes mounted under `/api/groups`. +pub fn subject_group_routes() -> Router> { + Router::new() + .route("/", post(create_group)) + .route("/", get(list_groups)) + .route("/search", get(search_groups)) + .route("/{id}", get(get_group)) + .route("/{id}", patch(update_group)) + .route("/{id}", delete(delete_group)) + .route("/{id}/members", get(list_members)) + .route("/{id}/members", post(add_member)) + .route("/{id}/members/user/{uid}", delete(remove_user_member)) + .route("/{id}/members/group/{gid}", delete(remove_group_member)) + .route("/{id}/effective-members", get(list_effective_members)) +} + +fn service( + state: &AppState, +) -> Result<&Arc, AppError> +{ + state + .subject_group_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Subject-group service not configured")) +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +/// Create a new ReBAC subject group. Admin-only. The name must match the +/// RFC 5321 local-part shape and be globally unique (case-insensitive). +#[utoipa::path( + post, + path = "/api/groups", + request_body = CreateGroupRequest, + responses( + (status = 201, description = "Group created", body = GroupDto), + (status = 400, description = "Invalid name (RFC 5321 violation)"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 409, description = "Group with this name already exists"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_create" +)] +pub async fn create_group( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result { + let (caller_id, _) = require_admin(&state, &headers).await?; + let svc = service(&state)?; + let group = svc + .create(&req.name, req.description, caller_id) + .await + .map_err(AppError::from)?; + // Reached require_admin → caller is admin → can_manage is always true here. + // New groups start with zero direct members. + Ok(( + StatusCode::CREATED, + Json(GroupDto::from_group(group, true, 0)), + )) +} + +/// List subject groups (paginated). Admin-only. +#[utoipa::path( + get, + path = "/api/groups", + params(ListGroupsQuery), + responses( + (status = 200, description = "Paginated list of groups", body = GroupListDto), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_list" +)] +pub async fn list_groups( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Result { + require_admin(&state, &headers).await?; + let svc = service(&state)?; + let (items, total) = svc + .list_with_counts(q.limit, q.offset, q.q.as_deref()) + .await + .map_err(AppError::from)?; + // Admin-gated handler → every row is manageable by the caller. + Ok(Json(GroupListDto { + items: items + .into_iter() + .map(|(g, member_count)| GroupDto::from_group(g, true, member_count)) + .collect(), + total, + })) +} + +/// Search non-virtual groups by name substring. Authenticated only (no +/// admin role required) — backs the share-dialog recipient autocomplete. +#[utoipa::path( + get, + path = "/api/groups/search", + params(SearchGroupsQuery), + responses( + (status = 200, description = "Matching non-virtual groups", body = [GroupDto]), + (status = 401, description = "Unauthorized"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_search" +)] +pub async fn search_groups( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Result { + // Any authenticated user can discover groups for the share dialog — + // membership lists remain admin-only via list_members. + let (_caller_id, role) = require_authenticated(&state, &headers).await?; + let can_manage = role == "admin"; + let svc = service(&state)?; + // The share-dialog autocomplete doesn't render a member-count chip, so + // emit 0 rather than spending a `COUNT(*)` per row. Frontend consumers + // that need the real count fetch it via `/api/groups/{id}` instead. + let items = svc + .search_for_share(&q.q, q.limit) + .await + .map_err(AppError::from)?; + Ok(Json( + items + .into_iter() + .map(|g| GroupDto::from_group(g, can_manage, 0)) + .collect::>(), + )) +} + +/// Fetch a single group's details. Admin-only. +#[utoipa::path( + get, + path = "/api/groups/{id}", + params(("id" = Uuid, Path, description = "Group ID")), + responses( + (status = 200, description = "Group details", body = GroupDto), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 404, description = "Group not found"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_get" +)] +pub async fn get_group( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + require_admin(&state, &headers).await?; + let svc = service(&state)?; + let group = svc.get_by_id(id).await.map_err(AppError::from)?; + let member_count = svc.count_members(id).await.map_err(AppError::from)?; + Ok(Json(GroupDto::from_group(group, true, member_count))) +} + +/// Update a group's metadata. Admin-only. v1 only persists name renames. +#[utoipa::path( + patch, + path = "/api/groups/{id}", + params(("id" = Uuid, Path, description = "Group ID")), + request_body = UpdateGroupRequest, + responses( + (status = 200, description = "Updated group", body = GroupDto), + (status = 400, description = "Invalid name"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required, or group is virtual"), + (status = 404, description = "Group not found"), + (status = 409, description = "Name already taken"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_update" +)] +pub async fn update_group( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Json(req): Json, +) -> Result { + let (caller_id, _) = require_admin(&state, &headers).await?; + let svc = service(&state)?; + + // v1 only supports renaming; description-only updates are silently + // accepted as a no-op so the API surface is forward-compatible. + let group = match req.name { + Some(new_name) => svc + .rename(id, &new_name, caller_id) + .await + .map_err(AppError::from)?, + None => svc.get_by_id(id).await.map_err(AppError::from)?, + }; + let member_count = svc.count_members(id).await.map_err(AppError::from)?; + Ok(Json(GroupDto::from_group(group, true, member_count))) +} + +/// Delete a group. Cascades to `subject_group_members` (FK) and to +/// `access_grants` rows referencing this group as a subject. Admin-only. +#[utoipa::path( + delete, + path = "/api/groups/{id}", + params(("id" = Uuid, Path, description = "Group ID")), + responses( + (status = 204, description = "Group deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required, or group is virtual"), + (status = 404, description = "Group not found"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_delete" +)] +pub async fn delete_group( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let (caller_id, _) = require_admin(&state, &headers).await?; + let svc = service(&state)?; + svc.delete(id, caller_id).await.map_err(AppError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +/// List the *direct* members of a group (one level only). Admin-only. +#[utoipa::path( + get, + path = "/api/groups/{id}/members", + params(("id" = Uuid, Path, description = "Group ID")), + responses( + (status = 200, description = "Direct members", body = [GroupMemberDto]), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 404, description = "Group not found"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_list_members" +)] +pub async fn list_members( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + require_admin(&state, &headers).await?; + let svc = service(&state)?; + let members = svc.list_direct_members(id).await.map_err(AppError::from)?; + Ok(Json( + members + .into_iter() + .map(GroupMemberDto::from) + .collect::>(), + )) +} + +/// Add a member to a group. Exactly one of `user_id` / `group_id` must be +/// provided. Adding a group-member runs a write-time cycle check and a +/// nesting-depth check (max 8). Admin-only. +#[utoipa::path( + post, + path = "/api/groups/{id}/members", + params(("id" = Uuid, Path, description = "Group ID")), + request_body = AddSubjectGroupMemberRequest, + responses( + (status = 201, description = "Member added"), + (status = 400, description = "Invalid request, cycle would be created, or depth limit exceeded"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required, or virtual group"), + (status = 404, description = "Group not found"), + (status = 409, description = "Member already in group"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_add_member" +)] +pub async fn add_member( + State(state): State>, + headers: HeaderMap, + Path(group_id): Path, + Json(req): Json, +) -> Result { + let (caller_id, _) = require_admin(&state, &headers).await?; + let svc = service(&state)?; + + let member = match (req.user_id, req.group_id) { + (Some(uid), None) => GroupMember::User(uid), + (None, Some(gid)) => GroupMember::Group(gid), + (Some(_), Some(_)) => { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Provide exactly one of user_id or group_id, not both", + "InvalidInput", + )); + } + (None, None) => { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Provide user_id or group_id", + "InvalidInput", + )); + } + }; + + svc.add_member(group_id, member, caller_id) + .await + .map_err(AppError::from)?; + Ok(StatusCode::CREATED) +} + +/// Remove a user-member from a group. Admin-only. +#[utoipa::path( + delete, + path = "/api/groups/{id}/members/user/{uid}", + params( + ("id" = Uuid, Path, description = "Group ID"), + ("uid" = Uuid, Path, description = "User ID to remove"), + ), + responses( + (status = 204, description = "Member removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required, or virtual group"), + (status = 404, description = "Group or member not found"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_remove_user_member" +)] +pub async fn remove_user_member( + State(state): State>, + headers: HeaderMap, + Path((group_id, uid)): Path<(Uuid, Uuid)>, +) -> Result { + let (caller_id, _) = require_admin(&state, &headers).await?; + let svc = service(&state)?; + svc.remove_member(group_id, GroupMember::User(uid), caller_id) + .await + .map_err(AppError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Remove a nested group-member from a group. Admin-only. +#[utoipa::path( + delete, + path = "/api/groups/{id}/members/group/{gid}", + params( + ("id" = Uuid, Path, description = "Parent group ID"), + ("gid" = Uuid, Path, description = "Child group ID to remove"), + ), + responses( + (status = 204, description = "Member removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required, or virtual group"), + (status = 404, description = "Group or member not found"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_remove_group_member" +)] +pub async fn remove_group_member( + State(state): State>, + headers: HeaderMap, + Path((group_id, gid)): Path<(Uuid, Uuid)>, +) -> Result { + let (caller_id, _) = require_admin(&state, &headers).await?; + let svc = service(&state)?; + svc.remove_member(group_id, GroupMember::Group(gid), caller_id) + .await + .map_err(AppError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +/// List every user transitively reached through this group (members of +/// members of members, etc.). Used by admin / audit tooling. Admin-only. +#[utoipa::path( + get, + path = "/api/groups/{id}/effective-members", + params(("id" = Uuid, Path, description = "Group ID")), + responses( + (status = 200, description = "Flat list of transitively-reached user IDs", body = [Uuid]), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 404, description = "Group not found"), + ), + security(("bearerAuth" = [])), + tag = "groups", + operation_id = "subject_group_effective_members" +)] +pub async fn list_effective_members( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + require_admin(&state, &headers).await?; + let svc = service(&state)?; + let users = svc + .list_transitive_users(id) + .await + .map_err(AppError::from)?; + Ok(Json(users)) +} diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 1682a549..f989b054 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -230,6 +230,18 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::grant_handler::list_shared_with_me, handlers::grant_handler::list_outgoing, handlers::grant_handler::list_on_resource, + // Subject-group handlers (ReBAC named groups) — free functions + handlers::subject_group_handler::create_group, + handlers::subject_group_handler::list_groups, + handlers::subject_group_handler::search_groups, + handlers::subject_group_handler::get_group, + handlers::subject_group_handler::update_group, + handlers::subject_group_handler::delete_group, + handlers::subject_group_handler::list_members, + handlers::subject_group_handler::add_member, + handlers::subject_group_handler::remove_user_member, + handlers::subject_group_handler::remove_group_member, + handlers::subject_group_handler::list_effective_members, ), components( schemas( @@ -319,6 +331,13 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; GrantDto, SharedWithMeDto, SharedWithMeItemDto, + // Subject-group (ReBAC named groups) schemas + handlers::subject_group_handler::CreateGroupRequest, + handlers::subject_group_handler::UpdateGroupRequest, + handlers::subject_group_handler::AddSubjectGroupMemberRequest, + handlers::subject_group_handler::GroupDto, + handlers::subject_group_handler::GroupListDto, + handlers::subject_group_handler::GroupMemberDto, ) ), tags( @@ -339,6 +358,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; (name = "contacts", description = "Address books, contacts, and groups endpoints"), (name = "admin", description = "Admin management endpoints"), (name = "grants", description = "ReBAC grant management endpoints"), + (name = "groups", description = "ReBAC subject-group management endpoints (named, nestable, root-owned)"), ), info( title = "OxiCloud API", diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 90e22246..c79592b9 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -563,6 +563,14 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { let admin_router = admin_handler::admin_routes().with_state(app_state.clone()); router = router.nest("/admin", admin_router); + // ReBAC subject-group management. All mutating routes are admin-gated; + // /api/groups/search is authenticated-only so the share dialog can list + // groups as recipients. + let group_router = + crate::interfaces::api::handlers::subject_group_handler::subject_group_routes() + .with_state(app_state.clone()); + router = router.nest("/groups", group_router); + // Transparent compression (gzip + brotli) for all API responses. // tower-http negotiates via Accept-Encoding and skips already-compressed // content types automatically. No manual compression in handlers. diff --git a/src/interfaces/middleware/admin.rs b/src/interfaces/middleware/admin.rs new file mode 100644 index 00000000..b8662601 --- /dev/null +++ b/src/interfaces/middleware/admin.rs @@ -0,0 +1,92 @@ +//! Admin role guard — shared across handlers that gate on `claims.role == "admin"`. +//! +//! Extracted from `admin_handler.rs::admin_guard` so the subject-group +//! handler (and any future admin-only surface) can reuse the same code path +//! without duplication. +//! +//! Returns `(user_id, role)` on success so callers have the caller's UUID +//! for audit / ownership purposes. + +use axum::http::{HeaderMap, StatusCode, header}; +use uuid::Uuid; + +use crate::application::ports::auth_ports::TokenServicePort; +use crate::common::di::AppState; +use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value}; +use crate::interfaces::errors::AppError; + +/// Validate the request's JWT (from the `Authorization: Bearer …` header +/// or the access-token cookie) and require `claims.role == "admin"`. +/// +/// On success returns `(user_id, role)`; on failure returns: +/// - 401 if no token / invalid token, +/// - 403 if the token is valid but the role is not `admin`, +/// - 500 if the auth service is not configured. +pub async fn require_admin( + state: &AppState, + headers: &HeaderMap, +) -> Result<(Uuid, String), AppError> { + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let token = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string())) + .or_else(|| extract_cookie_value(headers, ACCESS_COOKIE)) + .ok_or_else(|| AppError::unauthorized("Authorization token required"))?; + + let claims = auth + .token_service + .validate_token(&token) + .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; + + if claims.role != "admin" { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Admin access required", + "Forbidden", + )); + } + + Ok(( + Uuid::parse_str(&claims.sub) + .map_err(|_| AppError::internal_error("Invalid user ID in token"))?, + claims.role, + )) +} + +/// Validate the request's JWT (any role) and return `(user_id, role)`. +/// +/// Like `require_admin` but does not enforce the admin role — useful for +/// share-dialog autocomplete and similar surfaces that need a logged-in +/// caller but don't care about their role. +pub async fn require_authenticated( + state: &AppState, + headers: &HeaderMap, +) -> Result<(Uuid, String), AppError> { + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let token = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string())) + .or_else(|| extract_cookie_value(headers, ACCESS_COOKIE)) + .ok_or_else(|| AppError::unauthorized("Authorization token required"))?; + + let claims = auth + .token_service + .validate_token(&token) + .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; + + Ok(( + Uuid::parse_str(&claims.sub) + .map_err(|_| AppError::internal_error("Invalid user ID in token"))?, + claims.role, + )) +} diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index 2018351e..82a81905 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod auth; pub mod csrf; pub mod rate_limit; diff --git a/static/css/components/groupsModal.css b/static/css/components/groupsModal.css new file mode 100644 index 00000000..881aff80 --- /dev/null +++ b/static/css/components/groupsModal.css @@ -0,0 +1,308 @@ +/* ── Groups management modal — content styles ───────────────────────────────── + * + * Hosted by Modal.openPanel(). Only this file's content scrolls; the overlay, + * container, header and footer come from modals.css. + * + * Two states share the same body: list (rows of groups + Create button) and + * detail (single-group editor). Switching is a body-level replaceChildren + * call; the modal frame stays open. + * + * BEM root: `.groups-modal`. All colours via design tokens. + * ───────────────────────────────────────────────────────────────────────── */ + +.groups-modal { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 20px; +} + +/* ── Common ─────────────────────────────────────────────────────────────── */ + +.groups-modal__status, +.groups-modal__empty-line { + color: var(--color-text-subtle); + font-size: 13px; + padding: 8px 0; +} + +.groups-modal__error { + color: var(--color-danger-text); + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-alt); + border-radius: 6px; + padding: 10px 12px; + font-size: 13px; +} + +.groups-modal__inline-error { + margin: 0 0 8px 0; + color: var(--color-danger-text); + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-alt); + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; +} + +.groups-modal__section-title { + font-size: 12px; + font-weight: 600; + color: var(--color-text-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +/* ── List view ──────────────────────────────────────────────────────────── */ + +.groups-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.groups-modal__subtitle { + font-size: 14px; + color: var(--color-text-secondary); +} + +.groups-modal__create-btn { + flex-shrink: 0; +} + +.groups-modal__list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 60vh; + overflow-y: auto; +} + +.groups-modal__row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.groups-modal__row:hover, +.groups-modal__row:focus-visible { + background: var(--color-bg-hover); + outline: none; +} + +.groups-modal__row-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.groups-modal__row-desc { + color: var(--color-text-subtle); + font-size: 12px; + margin-left: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} + +.groups-modal__row-badge { + background: var(--color-bg-muted); + color: var(--color-text-subtle); + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; + margin-left: 8px; +} + +.groups-modal__row-count { + color: var(--color-text-subtle); + font-size: 12px; + flex-shrink: 0; + margin-left: auto; + padding-left: 8px; + white-space: nowrap; +} + +.groups-modal__load-more { + align-self: center; + margin-top: 12px; +} + +.groups-modal__empty { + text-align: center; + color: var(--color-text-subtle); + padding: 32px 12px; +} + +.groups-modal__empty-icon { + font-size: 32px; + color: var(--color-text-faint); + margin-bottom: 8px; + display: block; +} + +/* ── Detail view ────────────────────────────────────────────────────────── */ + +.groups-modal__detail-header { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border); +} + +.groups-modal__back-btn { + flex-shrink: 0; +} + +.groups-modal__detail-title-wrap { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.groups-modal__members { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 40vh; + overflow-y: auto; +} + +.groups-modal__member-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + border-radius: 6px; + transition: background-color 0.12s ease; +} + +.groups-modal__member-row:hover { + background: var(--color-bg-hover); +} + +.groups-modal__member-remove { + flex-shrink: 0; + color: var(--color-text-subtle); + width: 28px; + height: 28px; + padding: 0; + border-radius: 50%; +} + +.groups-modal__member-remove:hover { + color: var(--color-danger-text); + background: var(--color-danger-bg); +} + +/* ── Add-member row ─────────────────────────────────────────────────────── */ + +.groups-modal__add-row { + position: relative; + margin-top: 8px; +} + +.groups-modal__add-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-surface); + color: var(--color-text); + font-size: 13px; +} + +.groups-modal__add-input:focus { + outline: 2px solid var(--color-accent-ring); + outline-offset: -1px; + border-color: var(--color-accent); +} + +.groups-modal__add-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: 0 4px 12px var(--color-shadow); + max-height: 240px; + overflow-y: auto; + z-index: 10; +} + +.groups-modal__add-dropdown.hidden { + display: none; +} + +.groups-modal__add-item { + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.groups-modal__add-item:hover, +.groups-modal__add-item:focus-visible { + background: var(--color-bg-hover); + outline: none; +} + +/* ── Destructive footer ─────────────────────────────────────────────────── */ + +.groups-modal__footer { + display: flex; + justify-content: flex-start; + padding-top: 12px; + border-top: 1px solid var(--color-border); + margin-top: 8px; +} + +/* ── Inline create / rename / confirm-delete forms ──────────────────────── */ +/* Modal is a singleton; nesting Modal.prompt inside an open openPanel() + * can't surface its input. We render the form inside the panel body + * instead, swapping back to list / detail on submit or cancel. */ + +.groups-modal__form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 0; +} + +.groups-modal__form-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); +} + +.groups-modal__form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.groups-modal__inline-error.hidden { + display: none; +} diff --git a/static/css/components/userVignette.css b/static/css/components/userVignette.css index 7db2ef61..b11bb042 100644 --- a/static/css/components/userVignette.css +++ b/static/css/components/userVignette.css @@ -140,10 +140,39 @@ /* When a photo is available the JS replaces the initials text with an . The avatar keeps its background color as a fallback while loading. */ -.user-vignette__avatar img { +.user-vignette__avatar img, +.user-vignette__avatar .oxi-icon { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block; } + +/* ── Group variant ────────────────────────────────────────────────────────── + * `.user-vignette-group` is built by `components/groupVignette.js`. It reuses + * the user-vignette layout + size modifiers (`--xs/sm/md/list/lg/menu/xl`) + * so a row built from a group can swap in for a user without layout shift. + * + * The avatar circle is filled with a neutral surface colour and contains a + * `fa-user-group` icon instead of initials/photo. Single hue for every group + * (rather than the deterministic palette user avatars use) — this is a quick + * visual signal that the row represents a group, not a person. */ + +.user-vignette-group .user-vignette__avatar { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} + +.user-vignette-group .user-vignette__avatar i { + /* Scale the inline-SVG icon (oxi-icon, replaced by icons.js) to fit the + circle. The base `` is 1em — adjust so it sits centred with a small + padding inside the badge regardless of size modifier. */ + width: 62%; + height: 62%; +} + +.user-vignette-group .user-vignette__avatar .oxi-icon { + width: 100%; + height: 100%; +} diff --git a/static/css/main.css b/static/css/main.css index 93d68f9f..660aa9b7 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -22,6 +22,7 @@ @import url("./components/modals.css"); @import url("./components/shareDialog.css"); @import url("./components/shareModal.css"); +@import url("./components/groupsModal.css"); @import url("./components/userVignette.css"); @import url("./components/linkChip.css"); @import url("./components/uploadDropdown.css"); diff --git a/static/index.html b/static/index.html index 08a3a772..7d2630ce 100644 --- a/static/index.html +++ b/static/index.html @@ -198,6 +198,10 @@ Admin panel +