this implements first version (manageable only by admin right now)
routes:
GET /api/groups
List subject groups (paginated). Admin-only.
POST /api/groups
Create a new ReBAC subject group. Admin-only. The name must match the RFC 5321 local-part shape and be globally unique (case-insensitive).
GET /api/groups/search
Search non-virtual groups by name substring. Authenticated only (no admin role required) — backs the share-dialog recipient autocomplete.
GET /api/groups/{id}
Fetch a single group's details. Admin-only.
DELETE /api/groups/{id}
Delete a group. Cascades to `subject_group_members` (FK) and to `access_grants` rows referencing this group as a subject. Admin-only.
PATCH /api/groups/{id}
Update a group's metadata. Admin-only. v1 only persists name renames.
GET /api/groups/{id}/effective-members
List every user transitively reached through this group (members of members of members, etc.). Used by admin / audit tooling. Admin-only.
GET /api/groups/{id}/members
List the *direct* members of a group (one level only). Admin-only.
POST /api/groups/{id}/members
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.
DELETE /api/groups/{id}/members/group/{gid}
Remove a nested group-member from a group. Admin-only.
DELETE /api/groups/{id}/members/user/{uid}
Remove a user-member from a group. Admin-only.
fix hurl
groups
round
groups
28 KiB
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
Internalrepresents 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
Everyonevirtual group; external users are only reached via explicit per-grant action. UseAsSubjectpermission 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)
- New tables
auth.subject_groups+auth.subject_group_members. - Predefined
Internalvirtual group (well-known UUID, immutable). - CRUD REST API at
/api/groups/...(admin-only). - Membership add/remove with cycle + depth checks.
- Transitive-expansion function in
AuthorizationEngine, Moka-cached. - Cascade queries in
pg_acl_engine.rsupdated to usesubject_id = ANY(...). - Share-dialog autocomplete: extend to also return groups (via the new
authenticated
/api/groups/searchendpoint). - Audit logging via structured
tracing::info!(target = "audit", ...). - 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. ManageandUseAsSubjectpermissions on groups themselves (delegated group admin requires addingsubject_groupto theaccess_grantsresource_type CHECK and per-group authz).- Mailing-list dispatcher (the RFC-compliant naming preserves the door).
- Concrete syslog appender wiring (env-var driven
tracing-syslogortracing-journaldsubscriber — 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.
-- ── 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:
CITEXTextension is already enabled elsewhere — confirm via\dxagainst a dev DB; if not, the migration mustCREATE 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 = TRUEflag distinguishes the immutableInternalgroup; the service layer rejects member mutations on it. - No
created_bycolumn — groups have no owner by design.added_byon memberships still records who performed the edit, for audit.
Domain layer
New file: src/domain/entities/subject_group.rs.
pub struct SubjectGroup {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub is_virtual: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub enum GroupMember {
User(Uuid),
Group(Uuid),
}
impl SubjectGroup {
pub fn new(name: &str, description: Option<String>) -> Result<Self, DomainError> {
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<Regex> = 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<SubjectGroup, DomainError>get_by_id(id: Uuid) -> Result<Option<SubjectGroup>, DomainError>get_by_name(name: &str) -> Result<Option<SubjectGroup>, DomainError>(case-insensitive via CITEXT)list(limit, offset, name_query: Option<&str>) -> Result<(Vec<SubjectGroup>, u64 /*total*/), DomainError>rename(id: Uuid, new_name: &str) -> Result<SubjectGroup, DomainError>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<Vec<GroupMember>, DomainError>list_transitive_users(group_id: Uuid) -> Result<Vec<Uuid>, DomainError>(debug/audit)groups_for_user(user_id: Uuid) -> Result<HashSet<Uuid>, DomainError>(the hot path — recursive CTE)would_introduce_cycle(parent: Uuid, candidate_child_group: Uuid) -> Result<bool, DomainError>current_depth(group_id: Uuid) -> Result<u8, DomainError>(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)
-- 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
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)
-- 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_memberruns cycle + depth checks in the same transaction as the insert.deleteis guarded against removingis_virtual = TRUEgroups.- Service exposes one fast path:
is_user_in_group(user_id, group_id) -> bool, used by theInternalgroup 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):
/// 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<Arc<HashSet<Uuid>>, DomainError>;
Modify src/infrastructure/services/pg_acl_engine.rs:
-
Add a Moka cache field on the struct:
user_groups_cache: moka::future::Cache<Uuid, Arc<HashSet<Uuid>>>,constructed with:
Cache::builder() .max_capacity(50_000) .time_to_live(Duration::from_secs(30)) .build(); -
Implement
expand_subject:async fn expand_subject(&self, user_id: Uuid) -> Result<Arc<HashSet<Uuid>>, 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) } -
Modify the existing cascade queries (
folder_cascade_grant_existsat lines 92–121 andfile_cascade_grant_existsat lines 125–168 ofpg_acl_engine.rs):Replace
g.subject_id = $2withg.subject_id = ANY($2)and bind aVec<Uuid>produced byexpand_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 againstsubject_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:
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:
impl PgAclEngine {
async fn check(&self, subject: Subject, perm: Permission, resource: Resource)
-> Result<bool, DomainError>
{
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 AtomicU32s 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.idgroup→subject_type = 'group',subject_id = group.idcontact→ 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):
"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
test_create_group_validates_name_rfc5321— names with spaces / emojis rejected; valid names accepted.test_group_name_unique_case_insensitive— "Engineering" and "engineering" collide (CITEXT).test_cycle_check_rejects_direct_loop— adding A to A rejected by theno_selfCHECK or by the cycle CTE.test_cycle_check_rejects_two_step_loop— A∋B, B∋C, attempting C∋A rejected.test_cycle_check_rejects_eight_step_loop— same with longer chain.test_depth_cap_at_8— adding a 9th level rejected.test_transitive_expansion_includes_indirect_groups— A∋B, B∋C, U∈A returns {A, B, C} (plus U and Internal).test_internal_group_implicit_for_internal_users— non-external user's expansion containsINTERNAL_GROUP_ID; external user's doesn't.test_virtual_group_cannot_be_deleted— service rejects delete on the Internal group.test_member_can_be_user_or_group_but_not_both— XOR check.
Integration tests
test_authz_cascades_through_group— Alice in group G; G has read grant on file F; AuthorizationEngine::check returns Allow.test_authz_cascades_through_nested_group— Alice in B, B in A, A has grant. Expect Allow.test_grant_revoked_when_group_deleted— delete G; previous G-mediated grants no longer apply (FK CASCADE).test_user_removed_from_group_loses_access_after_cache_ttl— remove Alice from G; within 30s old answer may persist; after TTL, denied.test_internal_group_grant_visible_to_all_internal— grantreadon file F toINTERNAL_GROUP_ID; every internal user can read F; no external user can.
API tests (Hurl)
tests/api/groups_admin_only.hurl— non-admin POST /api/groups → 403.tests/api/groups_crud_happy_path.hurl— create, list, get, rename, delete.tests/api/groups_member_lifecycle.hurl— add user, add nested group, remove user, remove nested group.tests/api/groups_invalid_name.hurl— name with space → 400.
Verification
Pre-commit:
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):
docker compose up -d postgres.cargo run.- Obtain an admin JWT (log in via the existing login flow, copy the access token).
- Create a group:
curl -X POST /api/groups -H 'Authorization: Bearer …' \ -d '{"name":"engineering"}' - Confirm name validation rejects
Engineering Team(returns 400 witherror_code: group_name_invalid). - Add yourself as a member:
curl -X POST /api/groups/<gid>/members -d '{"user_id":"<you>"}' - Create a second group
qa, then addengineeringas a nested member ofqa. Confirm withGET /api/groups/<qa_id>/members. - Attempt to add
qaas a member ofengineering— expect 400 witherror_code: group_cycle. - In the browser, open a file → share dialog → type
eng. Theengineeringgroup should appear with the layer-group icon. Pick it and grantread. - Log in as a user who is a member of
engineering(directly or viaqacascading) — confirm the file is accessible. DELETE /api/groups/<gid>/members/user/<uid>. After 30 seconds (cache TTL), confirm access is denied.- Grant
readon a file to theInternalvirtual group (subject_id=00000000-0000-0000-0000-000000000001). Confirm every internal user has access. Confirm an external user (if available) does not. journalctl -t oxicloud | grep audit(or equivalent log inspection) — confirm one structured log line per group mutation, with thetarget="audit"andevent="group.*"fields.
Critical files to be modified
New files:
migrations/20260612000000_subject_groups.sqlsrc/domain/entities/subject_group.rssrc/domain/repositories/subject_group_repository.rssrc/infrastructure/repositories/pg/subject_group_pg_repository.rssrc/application/services/subject_group_service.rssrc/interfaces/api/handlers/subject_group_handler.rstests/api/groups_*.hurl
Modified files:
src/application/ports/authorization_ports.rs— addexpand_subject.src/infrastructure/services/pg_acl_engine.rs— add Moka cache field, implementexpand_subject, modifyfolder_cascade_grant_exists(lines 92–121) andfile_cascade_grant_exists(lines 125–168) to usesubject_id = ANY($caller_plus_groups).src/common/di.rs— wireSubjectGroupServiceintoAppState, pass user-repo intoPgAclEngineconstructor.src/interfaces/api/routes.rs— register the new handler.src/interfaces/middleware/— extractrequire_adminshared helper fromadmin_handler.rs:64-100.static/js/components/shareModal.js— parallel/api/groups/searchcall around line 310 to surface groups in the recipient autocomplete.static/locales/en.json+ 15 locale files — newerrors.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_guardpattern (admin_handler.rs:64-100) — extract shared.moka0.12.15 (Cargo.toml:38) — already present.tracing::info!— existing observability pipeline; just add thetarget: "audit"convention.ResourceListComponentanduserVignette— 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.