feat(group): 1st implementation of Groups
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
This commit is contained in:
@@ -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<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)
|
||||
|
||||
```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<Arc<HashSet<Uuid>>, 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<Uuid, Arc<HashSet<Uuid>>>,
|
||||
```
|
||||
|
||||
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<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)
|
||||
}
|
||||
```
|
||||
|
||||
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<Uuid>` 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<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 `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/<gid>/members -d '{"user_id":"<you>"}'
|
||||
```
|
||||
7. Create a second group `qa`, then add `engineering` as a nested member of
|
||||
`qa`. Confirm with `GET /api/groups/<qa_id>/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/<gid>/members/user/<uid>`. 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<PgAclEngine>) — 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.
|
||||
@@ -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 (`<name>@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 `<name>@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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SubjectGroupPgRepository>,
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl SubjectGroupService {
|
||||
pub fn new(repo: Arc<SubjectGroupPgRepository>, pool: Arc<PgPool>) -> 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<String>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SubjectGroup, DomainError> {
|
||||
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<SubjectGroup, DomainError> {
|
||||
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<SubjectGroup>, 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<i64, DomainError> {
|
||||
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<Vec<SubjectGroup>, 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<SubjectGroup, DomainError> {
|
||||
// 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<Vec<GroupMember>, 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<Vec<Uuid>, 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<HashSet<Uuid>, 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)
|
||||
}
|
||||
+18
-1
@@ -638,10 +638,16 @@ impl AppServiceFactory {
|
||||
|
||||
// 3a. Authorization engine — must exist before application services
|
||||
// because services hold an Arc<PgAclEngine> 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<dyn AuthorizationEngine>` (with
|
||||
/// `async_trait` boxing).
|
||||
pub authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
/// ReBAC subject-group management (CRUD + membership). `None` when the
|
||||
/// auth subsystem is not configured.
|
||||
pub subject_group_service:
|
||||
Option<Arc<crate::application::services::subject_group_service::SubjectGroupService>>,
|
||||
}
|
||||
|
||||
// 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<crate::infrastructure::repositories::pg::SubjectGroupPgRepository>,
|
||||
) -> Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -194,6 +194,52 @@ impl Error for CalendarEventError {}
|
||||
/// Type alias for CalendarEvent entity operation results
|
||||
pub type CalendarEventResult<T> = Result<T, CalendarEventError>;
|
||||
|
||||
// ============================================================================
|
||||
// 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<T> = Result<T, SubjectGroupError>;
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
@@ -254,5 +300,18 @@ mod tests {
|
||||
assert_error::<ShareError>();
|
||||
assert_error::<CalendarError>();
|
||||
assert_error::<CalendarEventError>();
|
||||
assert_error::<SubjectGroupError>();
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<String>,
|
||||
pub is_virtual: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<String>) -> SubjectGroupResult<Self> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SubjectGroup, SubjectGroupRepositoryError>;
|
||||
|
||||
/// 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<Option<SubjectGroup>, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Fetch a group by name. `CITEXT` makes this case-insensitive.
|
||||
async fn get_by_name(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Option<SubjectGroup>, 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<SubjectGroup>, 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<i64, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Rename the group. Fails on collision or invalid shape.
|
||||
async fn rename(
|
||||
&self,
|
||||
id: Uuid,
|
||||
new_name: &str,
|
||||
) -> Result<SubjectGroup, SubjectGroupRepositoryError>;
|
||||
|
||||
/// 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<Vec<GroupMember>, SubjectGroupRepositoryError>;
|
||||
|
||||
/// All users transitively in `group_id` (debug / audit / admin views).
|
||||
async fn list_transitive_users(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, 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<HashSet<Uuid>, SubjectGroupRepositoryError>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<PgPool>,
|
||||
}
|
||||
|
||||
impl SubjectGroupPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> 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::<Uuid, _>("id"),
|
||||
name: row.get::<String, _>("name"),
|
||||
description: row.get::<Option<String>, _>("description"),
|
||||
is_virtual: row.get::<bool, _>("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<SubjectGroup, SubjectGroupRepositoryError> {
|
||||
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<Option<SubjectGroup>, 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<Option<SubjectGroup>, 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<SubjectGroup>, 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::<i64, _>("member_count")))
|
||||
.collect();
|
||||
|
||||
Ok((items, total as u64))
|
||||
}
|
||||
|
||||
async fn count_members(&self, id: Uuid) -> Result<i64, SubjectGroupRepositoryError> {
|
||||
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<SubjectGroup, SubjectGroupRepositoryError> {
|
||||
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<Vec<GroupMember>, 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<Uuid> = row.get("member_user_id");
|
||||
let group_id: Option<Uuid> = 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<Vec<Uuid>, 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::<Uuid, _>("user_id")).collect())
|
||||
}
|
||||
|
||||
async fn groups_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<HashSet<Uuid>, 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::<Uuid, _>("group_id")).collect())
|
||||
}
|
||||
}
|
||||
@@ -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<PgPool>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
file_repo: Arc<FileBlobReadRepository>,
|
||||
/// Group repository — `None` only in test stubs that don't exercise authz.
|
||||
group_repo: Option<Arc<SubjectGroupPgRepository>>,
|
||||
/// 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<Uuid, Arc<HashSet<Uuid>>>,
|
||||
}
|
||||
|
||||
impl PgAclEngine {
|
||||
@@ -53,11 +78,17 @@ impl PgAclEngine {
|
||||
pool: Arc<PgPool>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
file_repo: Arc<FileBlobReadRepository>,
|
||||
group_repo: Arc<SubjectGroupPgRepository>,
|
||||
) -> 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<Arc<HashSet<Uuid>>, 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<Uuid> = 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<Uuid>), 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<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let exists: Option<i32> = 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<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let exists: Option<i32> = 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<bool, DomainError> {
|
||||
// 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<bool, DomainError> {
|
||||
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<Permission>,
|
||||
) -> Result<Vec<Grant>, 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<Row> = 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
|
||||
|
||||
@@ -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<Arc<AppState>> {
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateGroupRequest {
|
||||
pub name: Option<String>,
|
||||
/// Reserved for future use; v1 only persists the name in `rename`.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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<Uuid>,
|
||||
pub group_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct GroupDto {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// 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<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
/// 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<GroupDto>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum GroupMemberDto {
|
||||
User { id: Uuid },
|
||||
Group { id: Uuid },
|
||||
}
|
||||
|
||||
impl From<GroupMember> 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<Arc<AppState>> {
|
||||
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<crate::application::services::subject_group_service::SubjectGroupService>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<SearchGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 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::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(group_id): Path<Uuid>,
|
||||
Json(req): Json<AddSubjectGroupMemberRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((group_id, uid)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((group_id, gid)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
let users = svc
|
||||
.list_transitive_users(id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(users))
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -563,6 +563,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod rate_limit;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -140,10 +140,39 @@
|
||||
|
||||
/* When a photo is available the JS replaces the initials text with an <img>.
|
||||
The avatar <span> 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 `<i>` 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%;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -198,6 +198,10 @@
|
||||
<i class="fas fa-cogs"></i>
|
||||
<span data-i18n="user_menu.admin_panel">Admin panel</span>
|
||||
</button>
|
||||
<button class="user-menu-item user-menu-groups hidden" id="user-menu-groups">
|
||||
<i class="fas fa-user-group"></i>
|
||||
<span data-i18n="user_menu.manage_groups">Manage groups</span>
|
||||
</button>
|
||||
<button class="user-menu-item" id="user-menu-profile">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<span data-i18n="user_menu.profile">My profile</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createUserVignette } from '../components/userVignette.js';
|
||||
import { getCsrfHeaders } from '../core/csrf.js';
|
||||
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { groupsView } from '../views/groups/groupsView.js';
|
||||
|
||||
function setupUserMenu() {
|
||||
const wrapper = document.getElementById('user-menu-wrapper');
|
||||
@@ -15,6 +16,7 @@ function setupUserMenu() {
|
||||
const themeSegmented = document.getElementById('user-menu-theme-segmented');
|
||||
const aboutBtn = document.getElementById('user-menu-about');
|
||||
const adminBtn = document.getElementById('user-menu-admin');
|
||||
const groupsBtn = document.getElementById('user-menu-groups');
|
||||
const adminDivider = document.getElementById('user-menu-admin-divider');
|
||||
const profileBtn = document.getElementById('user-menu-profile');
|
||||
const roleBadge = document.getElementById('user-menu-role-badge');
|
||||
@@ -42,6 +44,12 @@ function setupUserMenu() {
|
||||
if (adminBtn) {
|
||||
isAdmin ? adminBtn.classList.remove('hidden') : adminBtn.classList.add('hidden');
|
||||
}
|
||||
if (groupsBtn) {
|
||||
// v1: admin-only. v2 will broaden to "has any manageable
|
||||
// group" — change the right-hand side here without touching
|
||||
// anything else.
|
||||
isAdmin ? groupsBtn.classList.remove('hidden') : groupsBtn.classList.add('hidden');
|
||||
}
|
||||
if (adminDivider) {
|
||||
isAdmin ? adminDivider.classList.remove('hidden') : adminDivider.classList.add('hidden');
|
||||
}
|
||||
@@ -117,6 +125,13 @@ function setupUserMenu() {
|
||||
});
|
||||
}
|
||||
|
||||
if (groupsBtn) {
|
||||
groupsBtn.addEventListener('click', () => {
|
||||
wrapper.classList.remove('open');
|
||||
groupsView.open();
|
||||
});
|
||||
}
|
||||
|
||||
if (profileBtn) {
|
||||
profileBtn.addEventListener('click', () => {
|
||||
wrapper.classList.remove('open');
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Display helpers for ReBAC subject groups.
|
||||
*
|
||||
* Server-side names of virtual groups (`Internal`, future `Everyone`, …) are
|
||||
* fixed RFC 5321 local-part strings so they can be email-addressable. The UI
|
||||
* surfaces them with a localised, capitalised label and a distinct icon.
|
||||
*
|
||||
* "Add a new virtual group" — frontend cost is:
|
||||
* 1. Add an entry to `VIRTUAL_NAME_KEYS` mapping the well-known UUID to an
|
||||
* `i18n` key.
|
||||
* 2. Add the i18n key + translations in the 16 locale files.
|
||||
*
|
||||
* Everything else (search results, vignettes, member rows, autocomplete)
|
||||
* picks the new group up automatically because the backend now returns
|
||||
* virtual groups in `/api/groups/search`.
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { INTERNAL_GROUP_ID } from '../model/groups.js';
|
||||
|
||||
/**
|
||||
* Map of well-known virtual-group UUIDs → i18n key for the human-readable
|
||||
* display name. Anything not in this map falls back to `group.name`.
|
||||
*
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
const VIRTUAL_NAME_KEYS = {
|
||||
[INTERNAL_GROUP_ID]: 'groups.virtual_internal_name'
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal shape needed by the display helpers. Both `GroupItem` (from
|
||||
* `/api/groups`) and shareModal's `GroupSuggestion` satisfy it, so callers
|
||||
* can pass either without an awkward upcast.
|
||||
*
|
||||
* @typedef {{id: string, name: string, is_virtual: boolean}} GroupDisplay
|
||||
*/
|
||||
|
||||
/**
|
||||
* Human-readable display name for a group. Virtual groups get a translated
|
||||
* label; user-defined groups display their raw name.
|
||||
*
|
||||
* @param {GroupDisplay} group
|
||||
* @returns {string}
|
||||
*/
|
||||
export function groupDisplayName(group) {
|
||||
if (group.is_virtual) {
|
||||
const key = VIRTUAL_NAME_KEYS[group.id];
|
||||
if (key) return i18n.t(key, group.name);
|
||||
}
|
||||
return group.name;
|
||||
}
|
||||
|
||||
/** FA class for system-managed (virtual) groups. `fa-people-roof` evokes a
|
||||
* shared roof / community, distinguishing virtual instance-wide groups
|
||||
* (Internal, future Everyone, …) from user-defined groups. Change here to
|
||||
* re-skin every virtual-group surface in the app in one place. */
|
||||
const VIRTUAL_ICON = 'fa-people-roof';
|
||||
|
||||
/** FA class for user-defined groups. */
|
||||
const REGULAR_ICON = 'fa-user-group';
|
||||
|
||||
/**
|
||||
* Pick the Font Awesome icon class for a group vignette. Virtual groups use
|
||||
* `VIRTUAL_ICON`; user-defined groups use `REGULAR_ICON`.
|
||||
*
|
||||
* @param {GroupDisplay} group
|
||||
* @returns {string}
|
||||
*/
|
||||
export function groupIconClass(group) {
|
||||
return group.is_virtual ? VIRTUAL_ICON : REGULAR_ICON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `groupIconClass` but for call sites that hold only the
|
||||
* `is_virtual` boolean — e.g. `MemberEntry._isVirtual` in shareModal,
|
||||
* where the full `GroupItem` isn't kept around.
|
||||
*
|
||||
* @param {boolean | undefined} isVirtual
|
||||
* @returns {string}
|
||||
*/
|
||||
export function groupIconClassByVirtual(isVirtual) {
|
||||
return isVirtual ? VIRTUAL_ICON : REGULAR_ICON;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Inline element representing a ReBAC subject group: user-group icon +
|
||||
* the group's name. Used by the share dialog (to display groups as share
|
||||
* recipients) and by the group-management view (to display nested-group
|
||||
* members).
|
||||
*
|
||||
* Visually mirrors `createUserVignette` from `./userVignette.js` so a row
|
||||
* built from one can swap in the other without layout shift. Picks
|
||||
* `fa-user-group` (a *people* icon) rather than `fa-layer-group`, which is
|
||||
* reserved across the app for the *grouping operator* on group-by menu pills
|
||||
* — keeping the two concepts visually distinct.
|
||||
*
|
||||
* subject group (this file) fa-user-group
|
||||
* grouping operator (group-by pills) fa-layer-group
|
||||
*/
|
||||
|
||||
import { escapeHtml } from '../core/formatters.js';
|
||||
|
||||
/**
|
||||
* Build the inline vignette.
|
||||
*
|
||||
* @param {string} name
|
||||
* Display name of the group (escaped before injection).
|
||||
* @param {'xs'|'sm'|'md'|'list'} [size='sm']
|
||||
* Matches the size scale of `createUserVignette`. The size class is
|
||||
* `user-vignette--${size}`; see `static/css/components/userVignette.css`.
|
||||
* @param {{ icon?: string }} [opts]
|
||||
* `icon`: FA class string without the `fa-` prefix (defaults to
|
||||
* `'fa-user-group'`). Used to signal virtual groups visually — see
|
||||
* `groupIconClass()` / `groupIconClassByVirtual()` in `./groupDisplay.js`
|
||||
* return a distinct icon for system-wide virtual groups (Internal,
|
||||
* future Everyone, …).
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function createGroupVignette(name, size = 'sm', { icon = 'fa-user-group' } = {}) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `user-vignette user-vignette-group user-vignette--${size}`;
|
||||
el.innerHTML = `<span class="user-vignette__avatar"><i class="fas ${escapeHtml(icon)}"></i></span><span class="user-vignette__name">${escapeHtml(name)}</span>`;
|
||||
return el;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { buildExpiryChip } from '../utils/expiryChip.js';
|
||||
import { buildPasswordChip } from '../utils/passwordChip.js';
|
||||
import { groupDisplayName, groupIconClass } from './groupDisplay.js';
|
||||
import { createGroupVignette } from './groupVignette.js';
|
||||
import { buildLinkChip } from './linkChip.js';
|
||||
import { buildResourceIcon } from './resourceIcon.js';
|
||||
import { buildRoleChip, roleLabel } from './roleChip.js';
|
||||
@@ -40,6 +42,26 @@ function _expiryState(expiresAt) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the unique group subject IDs across all grants in a page.
|
||||
* Callers feed the result to `groups.resolveGroups(...)` so rows can render
|
||||
* the group's display name instead of its UUID.
|
||||
*
|
||||
* @param {OutgoingResourceItem[]} items
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function collectGroupSubjectIds(items) {
|
||||
const out = new Set();
|
||||
for (const item of items) {
|
||||
for (const g of item.grants) {
|
||||
if (g.subject_type === 'group') out.add(g.subject_id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export { collectGroupSubjectIds };
|
||||
|
||||
class MySharesList {
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
@@ -55,6 +77,47 @@ class MySharesList {
|
||||
this._lastSwimKey = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._lastSwimEl = null;
|
||||
/**
|
||||
* Cached map of group subject UUID → full GroupItem. Populated by
|
||||
* the view via `setGroupMeta()` before each `render()` / `append()`
|
||||
* so group lane headers and identity rows render with the localised
|
||||
* name + virtual-aware icon.
|
||||
* @type {Record<string, import('../core/types.js').GroupItem>}
|
||||
*/
|
||||
this._groupMeta = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a resolved id→GroupItem map for group subjects expected in
|
||||
* the next render / append call. Replaces (does not merge) any previous
|
||||
* map.
|
||||
* @param {Record<string, import('../core/types.js').GroupItem>} map
|
||||
*/
|
||||
setGroupMeta(map) {
|
||||
this._groupMeta = map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort display name for a group subject. Falls back to the UUID
|
||||
* when no entry has been resolved yet — better than nothing while the
|
||||
* resolve query is in flight.
|
||||
* @param {string} groupId
|
||||
* @returns {string}
|
||||
*/
|
||||
_groupName(groupId) {
|
||||
const g = this._groupMeta[groupId];
|
||||
return g ? groupDisplayName(g) : groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon class for a group subject. Falls back to the regular group icon
|
||||
* if the entry hasn't been resolved yet.
|
||||
* @param {string} groupId
|
||||
* @returns {string}
|
||||
*/
|
||||
_groupIcon(groupId) {
|
||||
const g = this._groupMeta[groupId];
|
||||
return g ? groupIconClass(g) : 'fa-user-group';
|
||||
}
|
||||
|
||||
clear() {
|
||||
@@ -119,6 +182,8 @@ class MySharesList {
|
||||
let swimKey;
|
||||
if (grant.subject_type === 'user') {
|
||||
swimKey = `user:${grant.subject_id}`;
|
||||
} else if (grant.subject_type === 'group') {
|
||||
swimKey = `group:${grant.subject_id}`;
|
||||
} else if (grant.has_password) {
|
||||
swimKey = 'links:password';
|
||||
} else {
|
||||
@@ -201,6 +266,11 @@ class MySharesList {
|
||||
if (swimKey.startsWith('user:')) {
|
||||
return createUserVignette(grant.subject_id, 'list');
|
||||
}
|
||||
if (swimKey.startsWith('group:')) {
|
||||
return createGroupVignette(this._groupName(grant.subject_id), 'list', {
|
||||
icon: this._groupIcon(grant.subject_id)
|
||||
});
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ms-link-lane-label';
|
||||
const icon = document.createElement('i');
|
||||
@@ -258,8 +328,8 @@ class MySharesList {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ms-grant-row__identity';
|
||||
|
||||
if (grant.subject_type === 'user' && viewMode === 'sharedWith') {
|
||||
// Lane header is already the user — show the resource instead
|
||||
if ((grant.subject_type === 'user' || grant.subject_type === 'group') && viewMode === 'sharedWith') {
|
||||
// Lane header is already the subject — show the resource instead.
|
||||
el.appendChild(buildResourceIcon(item.resource, item.resource_type));
|
||||
const nameLink = document.createElement('a');
|
||||
nameLink.className = 'ms-identity__resource-name';
|
||||
@@ -272,6 +342,12 @@ class MySharesList {
|
||||
el.appendChild(nameLink);
|
||||
} else if (grant.subject_type === 'user') {
|
||||
el.appendChild(createUserVignette(grant.subject_id, 'xs'));
|
||||
} else if (grant.subject_type === 'group') {
|
||||
el.appendChild(
|
||||
createGroupVignette(this._groupName(grant.subject_id), 'xs', {
|
||||
icon: this._groupIcon(grant.subject_id)
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Token — link chip handles icon + label + copy-on-click
|
||||
el.appendChild(buildLinkChip(grant));
|
||||
@@ -355,7 +431,7 @@ class MySharesList {
|
||||
// Current expiry as YYYY-MM-DD (or null)
|
||||
const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null;
|
||||
|
||||
if (grant.subject_type === 'user') {
|
||||
if (grant.subject_type === 'user' || grant.subject_type === 'group') {
|
||||
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
|
||||
const isCurrent = grant.role === role;
|
||||
const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => {
|
||||
@@ -376,8 +452,9 @@ class MySharesList {
|
||||
menu.appendChild(this._menuSeparator());
|
||||
menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry));
|
||||
menu.appendChild(this._menuSeparator());
|
||||
const removeIcon = grant.subject_type === 'group' ? 'fas fa-user-group' : 'fas fa-user-times';
|
||||
menu.appendChild(
|
||||
this._menuItem('fas fa-user-times', i18n.t('myshares.removeAccess', 'Remove access'), true, async () => {
|
||||
this._menuItem(removeIcon, i18n.t('myshares.removeAccess', 'Remove access'), true, async () => {
|
||||
menu.remove();
|
||||
await grants.revokeGrant(grant.grant_id);
|
||||
this._removeRowAndCleanLane(rowEl);
|
||||
|
||||
@@ -19,14 +19,29 @@ import { i18n } from '../core/i18n.js';
|
||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||
import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { groups } from '../model/groups.js';
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
import { buildExpiryChip } from '../utils/expiryChip.js';
|
||||
import { buildPasswordChip } from '../utils/passwordChip.js';
|
||||
import { groupDisplayName, groupIconClass, groupIconClassByVirtual } from './groupDisplay.js';
|
||||
import { createGroupVignette } from './groupVignette.js';
|
||||
import { Modal } from './modal.js';
|
||||
import { createUserVignette } from './userVignette.js';
|
||||
|
||||
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
|
||||
|
||||
/**
|
||||
* A ReBAC subject group surfaced by `/api/groups/search`. Shape is a
|
||||
* deliberate superset of `ContactItem` so the staging / chip / commit code
|
||||
* paths can treat both uniformly, discriminating on the `_kind` field.
|
||||
*
|
||||
* @typedef {Object} GroupSuggestion
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {boolean} is_virtual
|
||||
* @property {'group'} _kind
|
||||
*/
|
||||
|
||||
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||
const ROLE_PERMISSIONS = {
|
||||
viewer: ['read'],
|
||||
@@ -34,6 +49,32 @@ const ROLE_PERMISSIONS = {
|
||||
admin: ['read', 'comment', 'create', 'update', 'share', 'delete']
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch up to ~8 ReBAC subject groups whose name matches `q`. Authenticated
|
||||
* endpoint; returns `[]` on any failure so the autocomplete degrades to
|
||||
* contacts-only rather than breaking the dialog.
|
||||
* @param {string} q
|
||||
* @returns {Promise<GroupSuggestion[]>}
|
||||
*/
|
||||
async function _searchGroups(q) {
|
||||
try {
|
||||
const res = await fetch(`/api/groups/search?q=${encodeURIComponent(q)}&limit=8`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
/** @type {Array<{id:string,name:string,is_virtual:boolean}>} */
|
||||
const items = await res.json();
|
||||
return items.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
is_virtual: !!g.is_virtual,
|
||||
_kind: /** @type {'group'} */ ('group')
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the highest role a set of grants represents for one subject.
|
||||
* @param {Grant[]} subjectGrants
|
||||
@@ -95,7 +136,7 @@ const shareModal = {
|
||||
/** @type {DraftLink[]} */
|
||||
_newLinks: [],
|
||||
|
||||
/** @type {ContactItem[]} */
|
||||
/** @type {Array<ContactItem | GroupSuggestion>} */
|
||||
_stagedUsers: [],
|
||||
|
||||
/** @type {ShareRoleEnum} */
|
||||
@@ -157,6 +198,26 @@ const shareModal = {
|
||||
|
||||
this._localMembers = _buildMembers(grantList);
|
||||
this._localLinks = linkList.map((share) => /** @type {LinkEntry} */ ({ share, _op: 'keep', _draft: null }));
|
||||
|
||||
// Group subjects in grants only carry their UUID — resolve full
|
||||
// GroupItem records so member rows render the localised name and
|
||||
// pick the correct icon (virtual groups get people-roof via
|
||||
// `groupIconClass`).
|
||||
const groupIds = new Set(this._localMembers.filter((m) => m.grant.subject.type === 'group').map((m) => m.grant.subject.id));
|
||||
if (groupIds.size > 0) {
|
||||
const resolved = await groups.resolveGroups(groupIds);
|
||||
for (const m of this._localMembers) {
|
||||
if (m.grant.subject.type === 'group') {
|
||||
const g = resolved[m.grant.subject.id];
|
||||
if (g) {
|
||||
m._displayName = groupDisplayName(g);
|
||||
m._isVirtual = g.is_virtual;
|
||||
} else {
|
||||
m._displayName = m.grant.subject.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('shareModal: load error', err);
|
||||
}
|
||||
@@ -307,7 +368,10 @@ const shareModal = {
|
||||
return;
|
||||
}
|
||||
debounce = setTimeout(async () => {
|
||||
const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]);
|
||||
// Search contacts (users) and ReBAC subject groups in parallel.
|
||||
// Group results are tagged with `_kind='group'` so the rest of
|
||||
// the dialog can render and commit them as group subjects.
|
||||
const [contacts, groupItems] = await Promise.all([addressBook.searchContacts(q, [SYSTEM_BOOK_ID]), _searchGroups(q)]);
|
||||
// Filter out the currently logged-in user — they cannot share with themselves
|
||||
const currentUserId = (() => {
|
||||
try {
|
||||
@@ -316,9 +380,12 @@ const shareModal = {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const filtered = currentUserId ? results.filter((c) => c.id !== currentUserId) : results;
|
||||
this._renderSuggestions(dropdown, filtered.slice(0, 8), (contact) => {
|
||||
this._stageUser(contact, input, dropdown, addBtn);
|
||||
const filtered = currentUserId ? contacts.filter((c) => c.id !== currentUserId) : contacts;
|
||||
// Groups first (they're a smaller, distinctively-iconed set),
|
||||
// then contacts. Cap at 8 combined.
|
||||
const combined = [...groupItems, ...filtered].slice(0, 8);
|
||||
this._renderSuggestions(dropdown, combined, (item) => {
|
||||
this._stageUser(item, input, dropdown, addBtn);
|
||||
});
|
||||
}, 200);
|
||||
});
|
||||
@@ -349,9 +416,9 @@ const shareModal = {
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {ContactItem[]} results
|
||||
* @param {(c: ContactItem) => void} onSelect
|
||||
* @param {HTMLElement} container
|
||||
* @param {Array<ContactItem | GroupSuggestion>} results
|
||||
* @param {(c: ContactItem | GroupSuggestion) => void} onSelect
|
||||
*/
|
||||
_renderSuggestions(container, results, onSelect) {
|
||||
container.replaceChildren();
|
||||
@@ -364,7 +431,12 @@ const shareModal = {
|
||||
item.className = 'smd-suggestion-item';
|
||||
item.tabIndex = 0;
|
||||
|
||||
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
|
||||
if (c._kind === 'group') {
|
||||
const g = /** @type {GroupSuggestion} */ (c);
|
||||
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
|
||||
} else {
|
||||
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
|
||||
}
|
||||
|
||||
const select = () => onSelect(c);
|
||||
item.addEventListener('click', select);
|
||||
@@ -377,15 +449,18 @@ const shareModal = {
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ContactItem} contact
|
||||
* @param {HTMLInputElement} inputEl
|
||||
* @param {HTMLElement} dropdown
|
||||
* @param {HTMLButtonElement} addBtn
|
||||
* @param {ContactItem | GroupSuggestion} contact
|
||||
* @param {HTMLInputElement} inputEl
|
||||
* @param {HTMLElement} dropdown
|
||||
* @param {HTMLButtonElement} addBtn
|
||||
*/
|
||||
_stageUser(contact, inputEl, dropdown, addBtn) {
|
||||
// Idempotent: skip duplicates and already-existing members
|
||||
const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m._op !== 'remove');
|
||||
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id);
|
||||
// Idempotent: skip duplicates and already-existing members. Match on
|
||||
// id *and* kind so a user and a group sharing a UUID collision (in
|
||||
// theory impossible; in practice harmless) wouldn't shadow each other.
|
||||
const kind = contact._kind === 'group' ? 'group' : 'user';
|
||||
const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m.grant.subject.type === kind && m._op !== 'remove');
|
||||
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id && (u._kind ?? 'user') === kind);
|
||||
if (alreadyMember || alreadyStaged) return;
|
||||
|
||||
this._stagedUsers.push(contact);
|
||||
@@ -422,20 +497,27 @@ const shareModal = {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'smd-chip';
|
||||
|
||||
const vignette = createUserVignette(c.id, 'xs');
|
||||
const visual =
|
||||
c._kind === 'group'
|
||||
? (() => {
|
||||
const g = /** @type {GroupSuggestion} */ (c);
|
||||
return createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
|
||||
})()
|
||||
: createUserVignette(c.id, 'xs');
|
||||
|
||||
const rm = document.createElement('button');
|
||||
rm.className = 'smd-chip-remove';
|
||||
rm.innerHTML = '×';
|
||||
rm.title = i18n.t('actions.remove', 'Remove');
|
||||
const kind = c._kind === 'group' ? 'group' : 'user';
|
||||
rm.addEventListener('click', () => {
|
||||
this._stagedUsers = this._stagedUsers.filter((u) => u.id !== c.id);
|
||||
this._stagedUsers = this._stagedUsers.filter((u) => !(u.id === c.id && (u._kind ?? 'user') === kind));
|
||||
this._refreshChips();
|
||||
const addBtn = /** @type {HTMLButtonElement|null} */ (document.querySelector('.smd-add-btn'));
|
||||
if (addBtn) addBtn.disabled = this._stagedUsers.length === 0;
|
||||
});
|
||||
|
||||
chip.appendChild(vignette);
|
||||
chip.appendChild(visual);
|
||||
chip.appendChild(rm);
|
||||
container.appendChild(chip);
|
||||
});
|
||||
@@ -443,12 +525,13 @@ const shareModal = {
|
||||
|
||||
_commitStagedUsers() {
|
||||
for (const contact of this._stagedUsers) {
|
||||
const subjectType = contact._kind === 'group' ? 'group' : 'user';
|
||||
/** @type {Grant} */
|
||||
const placeholderGrant = {
|
||||
id: '', // not yet persisted
|
||||
granted_at: '',
|
||||
granted_by: '',
|
||||
subject: { type: 'user', id: contact.id },
|
||||
subject: { type: subjectType, id: contact.id },
|
||||
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
|
||||
resource: { type: this._itemType, id: this._item?.id ?? '' }
|
||||
};
|
||||
@@ -457,7 +540,8 @@ const shareModal = {
|
||||
_grants: [], // no server grants yet — nothing to revoke on remove
|
||||
role: this._stagedRole,
|
||||
_op: 'new',
|
||||
expires_at: this._stagedExpiry
|
||||
expires_at: this._stagedExpiry,
|
||||
_displayName: contact._kind === 'group' ? /** @type {GroupSuggestion} */ (contact).name : undefined
|
||||
});
|
||||
}
|
||||
this._stagedUsers = [];
|
||||
@@ -528,7 +612,12 @@ const shareModal = {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'smd-member-row';
|
||||
|
||||
const vignette = createUserVignette(entry.grant.subject.id, 'md');
|
||||
const vignette =
|
||||
entry.grant.subject.type === 'group'
|
||||
? createGroupVignette(entry._displayName ?? entry.grant.subject.id, 'md', {
|
||||
icon: groupIconClassByVirtual(entry._isVirtual)
|
||||
})
|
||||
: createUserVignette(entry.grant.subject.id, 'md');
|
||||
|
||||
const roleSelect = document.createElement('select');
|
||||
roleSelect.className = 'smd-member-role-select';
|
||||
|
||||
@@ -161,6 +161,7 @@ const OxiIcons = {
|
||||
512,
|
||||
'M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 242.7-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7 288 32zM64 352c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-101.5 0-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352 64 352zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z'
|
||||
],
|
||||
'ellipsis-v': [128, 'M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z'],
|
||||
envelope: [
|
||||
512,
|
||||
'M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48L48 64zM0 176L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-208L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z'
|
||||
@@ -341,6 +342,10 @@ const OxiIcons = {
|
||||
512,
|
||||
'M36.4 353.2c4.1-14.6 11.8-27.9 22.6-38.7l181.2-181.2 33.9-33.9c16.6 16.6 51.3 51.3 104 104l33.9 33.9-33.9 33.9-181.2 181.2c-10.7 10.7-24.1 18.5-38.7 22.6L30.4 510.6c-8.3 2.3-17.3 0-23.4-6.2S-1.4 489.3 .9 481L36.4 353.2zm55.6-3.7c-4.4 4.7-7.6 10.4-9.3 16.6l-24.1 86.9 86.9-24.1c6.4-1.8 12.2-5.1 17-9.7L91.9 349.5zm354-146.1c-16.6-16.6-51.3-51.3-104-104L308 65.5C334.5 39 349.4 24.1 352.9 20.6 366.4 7 384.8-.6 404-.6S441.6 7 455.1 20.6l35.7 35.7C504.4 69.9 512 88.3 512 107.4s-7.6 37.6-21.2 51.1c-3.5 3.5-18.4 18.4-44.9 44.9z'
|
||||
],
|
||||
'people-roof': [
|
||||
576,
|
||||
'M302.3-12.6c-9-4.5-19.6-4.5-28.6 0l-256 128C1.9 123.3-4.5 142.5 3.4 158.3s27.1 22.2 42.9 14.3L288 51.8 529.7 172.6c15.8 7.9 35 1.5 42.9-14.3s1.5-35-14.3-42.9l-256-128zM288 272a56 56 0 1 0 0-112 56 56 0 1 0 0 112zm0 48c-53 0-96 43-96 96l0 32c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-32c0-53-43-96-96-96zM160 256a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm352 0a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM112 336c-44.2 0-80 35.8-80 80l0 33.1c0 17 13.8 30.9 30.9 30.9l87.8 0c-4.3-9.8-6.7-20.6-6.7-32l0-48c0-18.4 3.5-36 9.8-52.2-12.2-7.5-26.5-11.8-41.8-11.8zM425.4 480l87.8 0c17 0 30.9-13.8 30.9-30.9l0-33.1c0-44.2-35.8-80-80-80-15.3 0-29.6 4.3-41.8 11.8 6.3 16.2 9.8 33.8 9.8 52.2l0 48c0 11.4-2.4 22.2-6.7 32z'
|
||||
],
|
||||
play: [
|
||||
384,
|
||||
'M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z'
|
||||
@@ -453,6 +458,10 @@ const OxiIcons = {
|
||||
512,
|
||||
'M399 384.2C376.9 345.8 335.4 320 288 320l-64 0c-47.4 0-88.9 25.8-111 64.2c35.2 39.2 86.2 63.8 143 63.8s107.8-24.7 143-63.8zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm256 16a72 72 0 1 0 0-144 72 72 0 1 0 0 144z'
|
||||
],
|
||||
'user-group': [
|
||||
640,
|
||||
'M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM609.3 512l-137.8 0c5.4-9.4 8.6-20.3 8.6-32l0-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2l61.4 0C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z'
|
||||
],
|
||||
'user-plus': [
|
||||
640,
|
||||
'M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM504 312l0-64-64 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l64 0 0-64c0-13.3 10.7-24 24-24s24 10.7 24 24l0 64 64 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-64 0 0 64c0 13.3-10.7 24-24 24s-24-10.7-24-24z'
|
||||
|
||||
+46
-8
@@ -338,15 +338,20 @@
|
||||
|
||||
/**
|
||||
* One (subject, permissions) entry within an outgoing resource item.
|
||||
* Mirrors the server's `OutgoingResourceGrantDto`. `subject_type` is the
|
||||
* full set the backend may emit; the UI for My Shares filters out `'group'`
|
||||
* before rendering (see `_excludeGroupGrants` in mySharesList.js) so only
|
||||
* `'user'` and `'token'` rows actually reach the view layer there.
|
||||
*
|
||||
* @typedef {Object} OutgoingResourceGrant
|
||||
* @property {string} grant_id
|
||||
* @property {'user'|'token'} subject_type
|
||||
* @property {string} subject_id
|
||||
* @property {string} subject_display - Username (users) or share name (tokens).
|
||||
* @property {'viewer'|'editor'|'admin'} role
|
||||
* @property {string} granted_at - ISO-8601
|
||||
* @property {string|null} [expires_at] - ISO-8601 or absent.
|
||||
* @property {boolean} has_password - True when a token subject has a password set.
|
||||
* @property {string} grant_id
|
||||
* @property {'user'|'group'|'token'|'external'} subject_type
|
||||
* @property {string} subject_id
|
||||
* @property {string} subject_display - Username (users) or share name (tokens).
|
||||
* @property {'viewer'|'editor'|'admin'} role
|
||||
* @property {string} granted_at - ISO-8601
|
||||
* @property {string|null} [expires_at] - ISO-8601 or absent.
|
||||
* @property {boolean} has_password - True when a token subject has a password set.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -406,6 +411,8 @@
|
||||
* @property {string} created_at - ISO-8601
|
||||
* @property {string} updated_at - ISO-8601
|
||||
* @property {string} etag
|
||||
* @property {'user'|'group'} [_kind] - Discriminator added by the share-modal autocomplete when merging contacts with ReBAC subject groups. Absent (or 'user') for plain contacts; 'group' indicates the row is a subject-group suggestion with a `name` field instead of contact details.
|
||||
* @property {string} [name] - Present only when `_kind === 'group'` — the subject-group's display name.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -438,6 +445,8 @@
|
||||
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
|
||||
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
|
||||
* @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
|
||||
* @property {string} [_displayName] - Optional human-readable label (set for group subjects so the row can show the group name; user subjects resolve their name via `createUserVignette`).
|
||||
* @property {boolean} [_isVirtual] - True when this row's subject is a virtual (system-managed) group, so the vignette renders with the virtual-group icon.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -456,3 +465,32 @@
|
||||
* @property {string|null} expires_at - ISO-8601 date string or null.
|
||||
*/
|
||||
|
||||
// ------------------- ReBAC subject groups
|
||||
|
||||
/**
|
||||
* Mirrors `GroupDto` on the server (`subject_group_handler.rs::GroupDto`).
|
||||
* @typedef {Object} GroupItem
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {string|null} [description]
|
||||
* @property {boolean} is_virtual
|
||||
* @property {string} created_at - ISO-8601
|
||||
* @property {string} updated_at - ISO-8601
|
||||
* @property {boolean} can_manage - True if the current caller may rename / delete / curate the membership.
|
||||
* @property {number} member_count - Direct-member count (users + nested groups, one level). The
|
||||
* `/groups/search` endpoint emits 0 to skip a per-row COUNT(*);
|
||||
* list/get/create/update return the real value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Response from `GET /api/groups` — paginated list of groups.
|
||||
* @typedef {Object} GroupListResponse
|
||||
* @property {GroupItem[]} items
|
||||
* @property {number} total
|
||||
*/
|
||||
|
||||
/**
|
||||
* One direct member of a group (tagged union: user or nested group).
|
||||
* @typedef {{kind: 'user', id: string} | {kind: 'group', id: string}} GroupMemberItem
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @import {GroupItem, GroupListResponse, GroupMemberItem} from '../core/types.js'
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thin API client for ReBAC subject groups (`/api/groups/*`).
|
||||
*
|
||||
* Uses the global `fetch` (intercepted in `core/fetchWrapper.js` for
|
||||
* 401-refresh-retry and `ApiError` translation) and `getCsrfHeaders()` for
|
||||
* mutating verbs.
|
||||
*
|
||||
* v1: most endpoints are admin-only on the backend; `/api/groups/search` is
|
||||
* authenticated-only and powers the share-dialog recipient autocomplete.
|
||||
* v2 will relax the admin guard to per-group `Manage` permissions — every
|
||||
* caller here will keep working unchanged, but a non-admin may start
|
||||
* receiving 403s on `get`/`listMembers` for groups they can't see.
|
||||
*/
|
||||
|
||||
import { getCsrfHeaders } from '../core/csrf.js';
|
||||
|
||||
/** Well-known UUID of the predefined Internal virtual group (matches the
|
||||
* Rust constant `INTERNAL_GROUP_ID` in `src/domain/entities/subject_group.rs`). */
|
||||
const INTERNAL_GROUP_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
const groups = {
|
||||
/**
|
||||
* Paginated list. Admin-only on the server today.
|
||||
* @param {{limit?: number, offset?: number, q?: string|null}} [opts]
|
||||
* @returns {Promise<GroupListResponse>}
|
||||
*/
|
||||
async list({ limit = 50, offset = 0, q = null } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
if (q) params.set('q', q);
|
||||
const res = await fetch(`/api/groups?${params}`);
|
||||
if (!res.ok) throw await _err(res, 'list groups');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Search up to ~8 non-virtual groups whose name matches `q`.
|
||||
* Authenticated (not admin-gated) — used by the share-dialog autocomplete.
|
||||
* @param {string} q
|
||||
* @param {number} [limit=8]
|
||||
* @returns {Promise<GroupItem[]>}
|
||||
*/
|
||||
async search(q, limit = 8) {
|
||||
const params = new URLSearchParams({ q, limit: String(limit) });
|
||||
const res = await fetch(`/api/groups/search?${params}`);
|
||||
if (!res.ok) throw await _err(res, 'search groups');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a set of group IDs to full `GroupItem` records. Used after
|
||||
* loading grants (which only carry `subject_id`) so the UI can render
|
||||
* the group's name and pick a virtual-aware icon.
|
||||
*
|
||||
* Strategy: a single `/api/groups/search` call with empty `q` and a
|
||||
* generous limit covers any caller (admin or not) without needing the
|
||||
* admin-gated `GET /api/groups/{id}`. Virtual groups are now returned
|
||||
* by the search endpoint, so no special-casing is needed here.
|
||||
*
|
||||
* Unresolved IDs (deleted groups, or beyond the search limit) get a
|
||||
* synthetic stub so call sites never have to handle missing entries.
|
||||
*
|
||||
* @param {Iterable<string>} ids
|
||||
* @returns {Promise<Record<string, GroupItem>>}
|
||||
*/
|
||||
async resolveGroups(ids) {
|
||||
const wanted = new Set(ids);
|
||||
/** @type {Record<string, GroupItem>} */
|
||||
const out = {};
|
||||
if (wanted.size === 0) return out;
|
||||
|
||||
try {
|
||||
const items = await this.search('', 200);
|
||||
for (const g of items) {
|
||||
if (wanted.has(g.id)) out[g.id] = g;
|
||||
}
|
||||
} catch {
|
||||
// Network / auth failure — fall through to the stub fallback below.
|
||||
}
|
||||
|
||||
// Anything still unresolved → readable stub so the UI never shows a
|
||||
// raw UUID. `is_virtual: false` matches the safer (more restrictive)
|
||||
// visual treatment when in doubt.
|
||||
const now = new Date().toISOString();
|
||||
for (const id of wanted) {
|
||||
if (!(id in out)) {
|
||||
out[id] = {
|
||||
id,
|
||||
name: `Group ${id.slice(0, 8)}…`,
|
||||
description: null,
|
||||
is_virtual: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
can_manage: false,
|
||||
member_count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<GroupItem>}
|
||||
*/
|
||||
async get(id) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) throw await _err(res, 'get group');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{name: string, description?: string|null}} body
|
||||
* @returns {Promise<GroupItem>}
|
||||
*/
|
||||
async create(body) {
|
||||
const res = await fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw await _err(res, 'create group');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} newName
|
||||
* @returns {Promise<GroupItem>}
|
||||
*/
|
||||
async rename(id, newName) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ name: newName })
|
||||
});
|
||||
if (!res.ok) throw await _err(res, 'rename group');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteGroup(id) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw await _err(res, 'delete group');
|
||||
},
|
||||
|
||||
/**
|
||||
* Direct members (one level only, not transitive).
|
||||
* @param {string} id
|
||||
* @returns {Promise<GroupMemberItem[]>}
|
||||
*/
|
||||
async listMembers(id) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(id)}/members`);
|
||||
if (!res.ok) throw await _err(res, 'list members');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a user as a member.
|
||||
* @param {string} groupId
|
||||
* @param {string} userId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async addUserMember(groupId, userId) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
if (!res.ok) throw await _err(res, 'add user member');
|
||||
},
|
||||
|
||||
/**
|
||||
* Add another group as a nested member.
|
||||
* Backend runs the cycle + depth checks at write time.
|
||||
* @param {string} groupId
|
||||
* @param {string} memberGroupId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async addGroupMember(groupId, memberGroupId) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ group_id: memberGroupId })
|
||||
});
|
||||
if (!res.ok) throw await _err(res, 'add group member');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} groupId
|
||||
* @param {string} userId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async removeUserMember(groupId, userId) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members/user/${encodeURIComponent(userId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw await _err(res, 'remove user member');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} groupId
|
||||
* @param {string} memberGroupId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async removeGroupMember(groupId, memberGroupId) {
|
||||
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members/group/${encodeURIComponent(memberGroupId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw await _err(res, 'remove group member');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a thrown Error from a non-OK Response. The fetch interceptor turns
|
||||
* structured API errors into `ApiError`, so anything that lands here is
|
||||
* either a network failure or an error the interceptor already enriched.
|
||||
* @param {Response} res
|
||||
* @param {string} context
|
||||
* @returns {Promise<Error>}
|
||||
*/
|
||||
async function _err(res, context) {
|
||||
let detail = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const body = await res.text();
|
||||
if (body) detail += `: ${body}`;
|
||||
} catch {
|
||||
// body unreadable — fall through with status only.
|
||||
}
|
||||
return new Error(`${context} failed: ${detail}`);
|
||||
}
|
||||
|
||||
export { groups, INTERNAL_GROUP_ID };
|
||||
@@ -0,0 +1,825 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Subject-group management view.
|
||||
*
|
||||
* Reached from the user-menu "Manage groups" entry. Opens a `Modal.openPanel`
|
||||
* with a two-state UI:
|
||||
*
|
||||
* list — paginated list of groups + Create button
|
||||
* detail — single-group editor: members, add/remove, rename, delete
|
||||
*
|
||||
* Mutations commit immediately (POST/DELETE per click). The two states are
|
||||
* rendered into the same panel body — `_renderListInto()` and
|
||||
* `_renderDetailInto()` swap the body element, the modal frame stays open.
|
||||
*
|
||||
* Action buttons (Create, Rename, Delete, Add/Remove member) read
|
||||
* `group.can_manage` from the backend DTO and are hidden/disabled when
|
||||
* `false`. v1 returns `can_manage = (role === "admin")`; v2 will return it
|
||||
* from per-group `Manage` grants — no JS change required at that point.
|
||||
*/
|
||||
|
||||
import { groupDisplayName, groupIconClass } from '../../components/groupDisplay.js';
|
||||
import { createGroupVignette } from '../../components/groupVignette.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { createUserVignette } from '../../components/userVignette.js';
|
||||
import { escapeHtml } from '../../core/formatters.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { addressBook, SYSTEM_BOOK_ID } from '../../model/addressBook.js';
|
||||
import { groups } from '../../model/groups.js';
|
||||
|
||||
/**
|
||||
* @import {ContactItem, GroupItem, GroupMemberItem} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Localised "(N members)" label for a group list row. The project's i18n
|
||||
* helper is key→string with no built-in pluralisation, so we branch on the
|
||||
* three forms named by the plan and substitute `{count}` ourselves.
|
||||
*
|
||||
* @param {number} count
|
||||
* @returns {string}
|
||||
*/
|
||||
function _memberCountLabel(count) {
|
||||
if (count === 0) return i18n.t('groups.member_count_zero', 'no members');
|
||||
if (count === 1) return i18n.t('groups.member_count_one', '1 member');
|
||||
return i18n.t('groups.member_count_other', '{count} members').replace('{count}', String(count));
|
||||
}
|
||||
|
||||
const groupsView = {
|
||||
// ── State ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** @type {HTMLElement|null} — current panel body container */
|
||||
_bodyEl: null,
|
||||
|
||||
/** @type {GroupItem|null} — populated when in detail state */
|
||||
_currentGroup: null,
|
||||
|
||||
/** @type {GroupMemberItem[]} — direct members of the current group */
|
||||
_members: [],
|
||||
|
||||
/** @type {GroupItem[]} — most recent list page */
|
||||
_items: [],
|
||||
_nextOffset: 0,
|
||||
_hasMore: false,
|
||||
|
||||
// ── Public entry ──────────────────────────────────────────────────────
|
||||
|
||||
/** Open the management modal at the list view. */
|
||||
async open() {
|
||||
this._currentGroup = null;
|
||||
this._members = [];
|
||||
this._items = [];
|
||||
this._nextOffset = 0;
|
||||
this._hasMore = false;
|
||||
|
||||
this._bodyEl = document.createElement('div');
|
||||
this._bodyEl.className = 'groups-modal';
|
||||
|
||||
Modal.openPanel({
|
||||
title: i18n.t('groups.title', 'Manage groups'),
|
||||
icon: 'fa-user-group',
|
||||
content: this._bodyEl,
|
||||
confirmText: i18n.t('actions.close', 'Close'),
|
||||
cancelText: '',
|
||||
onConfirm: null
|
||||
});
|
||||
|
||||
await this._renderListInto(this._bodyEl);
|
||||
},
|
||||
|
||||
// ── List view ─────────────────────────────────────────────────────────
|
||||
|
||||
/** @param {HTMLElement} root */
|
||||
async _renderListInto(root) {
|
||||
root.replaceChildren();
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'groups-modal__header';
|
||||
|
||||
const subtitle = document.createElement('div');
|
||||
subtitle.className = 'groups-modal__subtitle';
|
||||
subtitle.textContent = i18n.t('groups.title', 'Manage groups');
|
||||
|
||||
const createBtn = document.createElement('button');
|
||||
createBtn.type = 'button';
|
||||
createBtn.className = 'btn btn-primary groups-modal__create-btn';
|
||||
createBtn.innerHTML = `<i class="fas fa-plus"></i> ${escapeHtml(i18n.t('groups.create_button', 'Create group'))}`;
|
||||
createBtn.addEventListener('click', () => this._promptCreate());
|
||||
|
||||
header.appendChild(subtitle);
|
||||
header.appendChild(createBtn);
|
||||
root.appendChild(header);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'groups-modal__list';
|
||||
root.appendChild(list);
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'groups-modal__status';
|
||||
status.textContent = i18n.t('groups.loading', 'Loading…');
|
||||
list.appendChild(status);
|
||||
|
||||
try {
|
||||
const page = await groups.list({ limit: PAGE_SIZE, offset: 0 });
|
||||
this._items = page.items;
|
||||
this._nextOffset = page.items.length;
|
||||
this._hasMore = page.items.length < page.total;
|
||||
list.replaceChildren();
|
||||
|
||||
if (page.items.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'groups-modal__empty';
|
||||
empty.innerHTML = `<i class="fas fa-user-group groups-modal__empty-icon"></i><p>${escapeHtml(i18n.t('groups.empty_state', 'No groups yet.'))}</p>`;
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const g of page.items) {
|
||||
list.appendChild(this._buildListRow(g));
|
||||
}
|
||||
|
||||
if (this._hasMore) {
|
||||
const more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'btn btn-ghost groups-modal__load-more';
|
||||
more.textContent = i18n.t('groups.load_more', 'Load more');
|
||||
more.addEventListener('click', async () => {
|
||||
more.disabled = true;
|
||||
const next = await groups.list({ limit: PAGE_SIZE, offset: this._nextOffset });
|
||||
more.remove();
|
||||
for (const g of next.items) list.appendChild(this._buildListRow(g));
|
||||
this._items = [...this._items, ...next.items];
|
||||
this._nextOffset += next.items.length;
|
||||
this._hasMore = this._items.length < next.total;
|
||||
if (this._hasMore) list.appendChild(more);
|
||||
});
|
||||
list.appendChild(more);
|
||||
}
|
||||
} catch (err) {
|
||||
list.replaceChildren();
|
||||
const errEl = document.createElement('div');
|
||||
errEl.className = 'groups-modal__error';
|
||||
errEl.textContent = /** @type {Error} */ (err).message;
|
||||
list.appendChild(errEl);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {GroupItem} g
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildListRow(g) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'groups-modal__row';
|
||||
row.tabIndex = 0;
|
||||
|
||||
// Vignette + meta
|
||||
const main = document.createElement('div');
|
||||
main.className = 'groups-modal__row-main';
|
||||
main.appendChild(createGroupVignette(groupDisplayName(g), 'md', { icon: groupIconClass(g) }));
|
||||
if (g.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'groups-modal__row-desc';
|
||||
desc.textContent = g.description;
|
||||
main.appendChild(desc);
|
||||
}
|
||||
if (g.is_virtual) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'groups-modal__row-badge';
|
||||
badge.textContent = i18n.t('groups.virtual_badge', 'System');
|
||||
main.appendChild(badge);
|
||||
}
|
||||
// Virtual groups (Internal, future Everyone, …) have no direct members
|
||||
// by construction — membership is computed implicitly by the engine —
|
||||
// so a literal "no members" chip would be misleading. Skip it.
|
||||
if (!g.is_virtual) {
|
||||
const memberChip = document.createElement('span');
|
||||
memberChip.className = 'groups-modal__row-count';
|
||||
memberChip.textContent = _memberCountLabel(g.member_count);
|
||||
main.appendChild(memberChip);
|
||||
}
|
||||
row.appendChild(main);
|
||||
|
||||
// Click row → open detail. Virtual groups are read-only but can still
|
||||
// be inspected.
|
||||
const openDetail = () => this._showDetail(g.id);
|
||||
row.addEventListener('click', openDetail);
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openDetail();
|
||||
}
|
||||
});
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render an inline name-entry form into the panel body. The Modal is a
|
||||
* singleton — calling `Modal.prompt()` from inside an open `openPanel()`
|
||||
* mutates the same overlay and doesn't surface a usable input field, so
|
||||
* we keep the create / rename flows inside this view's own body.
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
_renderCreateInto(root) {
|
||||
root.replaceChildren();
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'groups-modal__detail-header';
|
||||
|
||||
const back = document.createElement('button');
|
||||
back.type = 'button';
|
||||
back.className = 'btn btn-ghost groups-modal__back-btn';
|
||||
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
|
||||
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
|
||||
back.addEventListener('click', () => this._renderListInto(root));
|
||||
header.appendChild(back);
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'groups-modal__subtitle';
|
||||
title.textContent = i18n.t('groups.create_dialog_title', 'New group');
|
||||
header.appendChild(title);
|
||||
|
||||
root.appendChild(header);
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'groups-modal__form';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'groups-modal__form-label';
|
||||
label.textContent = i18n.t('groups.name_label', 'Name');
|
||||
form.appendChild(label);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'groups-modal__add-input';
|
||||
input.placeholder = i18n.t('groups.name_placeholder', 'engineering');
|
||||
input.autocomplete = 'off';
|
||||
label.appendChild(input);
|
||||
|
||||
const err = document.createElement('div');
|
||||
err.className = 'groups-modal__inline-error hidden';
|
||||
form.appendChild(err);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'groups-modal__form-actions';
|
||||
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn btn-ghost';
|
||||
cancel.textContent = i18n.t('actions.cancel', 'Cancel');
|
||||
cancel.addEventListener('click', () => this._renderListInto(root));
|
||||
|
||||
const save = document.createElement('button');
|
||||
save.type = 'button';
|
||||
save.className = 'btn btn-primary';
|
||||
save.textContent = i18n.t('actions.create', 'Create');
|
||||
|
||||
const submit = async () => {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
err.textContent = i18n.t('errors.group_name_invalid', 'Invalid name.');
|
||||
err.classList.remove('hidden');
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
save.disabled = true;
|
||||
try {
|
||||
await groups.create({ name: value });
|
||||
await this._renderListInto(root);
|
||||
} catch (e) {
|
||||
err.textContent = /** @type {Error} */ (e).message;
|
||||
err.classList.remove('hidden');
|
||||
save.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
save.addEventListener('click', submit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this._renderListInto(root);
|
||||
}
|
||||
});
|
||||
|
||||
actions.appendChild(cancel);
|
||||
actions.appendChild(save);
|
||||
form.appendChild(actions);
|
||||
|
||||
root.appendChild(form);
|
||||
|
||||
// Focus the input on next tick so the panel finishes rendering first.
|
||||
setTimeout(() => input.focus(), 0);
|
||||
},
|
||||
|
||||
_promptCreate() {
|
||||
if (this._bodyEl) this._renderCreateInto(this._bodyEl);
|
||||
},
|
||||
|
||||
// ── Detail view ───────────────────────────────────────────────────────
|
||||
|
||||
/** @param {string} groupId */
|
||||
async _showDetail(groupId) {
|
||||
if (!this._bodyEl) return;
|
||||
try {
|
||||
const [group, members] = await Promise.all([groups.get(groupId), groups.listMembers(groupId)]);
|
||||
this._currentGroup = group;
|
||||
this._members = members;
|
||||
this._renderDetailInto(this._bodyEl);
|
||||
} catch (err) {
|
||||
this._showFatalError(/** @type {Error} */ (err).message);
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {HTMLElement} root */
|
||||
_renderDetailInto(root) {
|
||||
const group = this._currentGroup;
|
||||
if (!group) return;
|
||||
root.replaceChildren();
|
||||
|
||||
// ── Header (back arrow + name + meta) ─────────────────────────────
|
||||
const header = document.createElement('div');
|
||||
header.className = 'groups-modal__detail-header';
|
||||
|
||||
const back = document.createElement('button');
|
||||
back.type = 'button';
|
||||
back.className = 'btn btn-ghost groups-modal__back-btn';
|
||||
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
|
||||
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
|
||||
back.addEventListener('click', () => this._renderListInto(root));
|
||||
header.appendChild(back);
|
||||
|
||||
const titleWrap = document.createElement('div');
|
||||
titleWrap.className = 'groups-modal__detail-title-wrap';
|
||||
titleWrap.appendChild(createGroupVignette(groupDisplayName(group), 'md', { icon: groupIconClass(group) }));
|
||||
|
||||
if (group.is_virtual) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'groups-modal__row-badge';
|
||||
badge.textContent = i18n.t('groups.virtual_badge', 'System');
|
||||
titleWrap.appendChild(badge);
|
||||
}
|
||||
header.appendChild(titleWrap);
|
||||
|
||||
if (group.can_manage && !group.is_virtual) {
|
||||
const rename = document.createElement('button');
|
||||
rename.type = 'button';
|
||||
rename.className = 'btn btn-ghost';
|
||||
rename.innerHTML = `<i class="fas fa-pen"></i>`;
|
||||
rename.title = i18n.t('actions.rename', 'Rename');
|
||||
rename.addEventListener('click', () => this._promptRename(group));
|
||||
header.appendChild(rename);
|
||||
}
|
||||
|
||||
root.appendChild(header);
|
||||
|
||||
// ── Members section ───────────────────────────────────────────────
|
||||
const membersSection = document.createElement('div');
|
||||
membersSection.className = 'groups-modal__members';
|
||||
|
||||
const membersHeader = document.createElement('div');
|
||||
membersHeader.className = 'groups-modal__section-title';
|
||||
membersHeader.textContent = i18n.t('groups.members_section', 'Members');
|
||||
membersSection.appendChild(membersHeader);
|
||||
|
||||
if (this._members.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'groups-modal__empty-line';
|
||||
empty.textContent = i18n.t('groups.no_members', 'No members yet.');
|
||||
membersSection.appendChild(empty);
|
||||
} else {
|
||||
for (const m of this._members) {
|
||||
membersSection.appendChild(this._buildMemberRow(m));
|
||||
}
|
||||
}
|
||||
root.appendChild(membersSection);
|
||||
|
||||
// ── Add-member row (only if can_manage) ───────────────────────────
|
||||
if (group.can_manage && !group.is_virtual) {
|
||||
root.appendChild(this._buildAddMemberRow());
|
||||
}
|
||||
|
||||
// ── Delete group (destructive footer) ─────────────────────────────
|
||||
if (group.can_manage && !group.is_virtual) {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'groups-modal__footer';
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn btn-danger';
|
||||
del.innerHTML = `<i class="fas fa-trash"></i> ${escapeHtml(i18n.t('groups.delete_group', 'Delete group'))}`;
|
||||
del.addEventListener('click', () => this._confirmDelete(group));
|
||||
footer.appendChild(del);
|
||||
root.appendChild(footer);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {GroupMemberItem} m
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildMemberRow(m) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'groups-modal__member-row';
|
||||
|
||||
if (m.kind === 'user') {
|
||||
row.appendChild(createUserVignette(m.id, 'sm', { showEmail: true }));
|
||||
} else {
|
||||
// Nested group — show the vignette. We don't pre-load the name
|
||||
// (it's just the id from the API). To get the name we'd need an
|
||||
// extra fetch; for v1, show the id-as-name (small UX cost) and
|
||||
// upgrade once `list_direct_members` returns enriched rows.
|
||||
row.appendChild(createGroupVignette(m.id, 'sm'));
|
||||
}
|
||||
|
||||
if (this._currentGroup?.can_manage && !this._currentGroup?.is_virtual) {
|
||||
const rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'btn btn-ghost groups-modal__member-remove';
|
||||
rm.innerHTML = '×';
|
||||
rm.title = i18n.t('groups.remove_member', 'Remove');
|
||||
rm.addEventListener('click', () => this._removeMember(m));
|
||||
row.appendChild(rm);
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
_buildAddMemberRow() {
|
||||
const group = this._currentGroup;
|
||||
if (!group) return document.createElement('div');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'groups-modal__add-row';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'groups-modal__add-input';
|
||||
input.placeholder = i18n.t('groups.add_member_placeholder', 'Add a user or group…');
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'groups-modal__add-dropdown hidden';
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let debounce = null;
|
||||
|
||||
/** @param {GroupMemberItem} m */
|
||||
const knownMemberKey = (m) => `${m.kind}:${m.id}`;
|
||||
const seen = new Set(this._members.map(knownMemberKey));
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
if (debounce) clearTimeout(debounce);
|
||||
const q = input.value.trim();
|
||||
if (!q) {
|
||||
dropdown.classList.add('hidden');
|
||||
dropdown.replaceChildren();
|
||||
return;
|
||||
}
|
||||
debounce = setTimeout(async () => {
|
||||
try {
|
||||
const [contacts, groupResults] = await Promise.all([addressBook.searchContacts(q, [SYSTEM_BOOK_ID]), groups.search(q)]);
|
||||
// Filter out the current group itself + already-members.
|
||||
const userHits = contacts.filter((c) => !seen.has(`user:${c.id}`)).slice(0, 5);
|
||||
const groupHits = groupResults.filter((g) => g.id !== group.id && !seen.has(`group:${g.id}`)).slice(0, 5);
|
||||
this._renderAddSuggestions(dropdown, userHits, groupHits);
|
||||
} catch (err) {
|
||||
dropdown.replaceChildren();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'groups-modal__error';
|
||||
e.textContent = /** @type {Error} */ (err).message;
|
||||
dropdown.appendChild(e);
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(e) => {
|
||||
if (!wrap.contains(/** @type {Node} */ (e.target))) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
{ once: false }
|
||||
);
|
||||
|
||||
wrap.appendChild(input);
|
||||
wrap.appendChild(dropdown);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} dropdown
|
||||
* @param {ContactItem[]} userHits
|
||||
* @param {GroupItem[]} groupHits
|
||||
*/
|
||||
_renderAddSuggestions(dropdown, userHits, groupHits) {
|
||||
dropdown.replaceChildren();
|
||||
if (userHits.length === 0 && groupHits.length === 0) {
|
||||
dropdown.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const group = this._currentGroup;
|
||||
if (!group) return;
|
||||
|
||||
for (const g of groupHits) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'groups-modal__add-item';
|
||||
item.tabIndex = 0;
|
||||
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
|
||||
item.addEventListener('click', async () => {
|
||||
dropdown.classList.add('hidden');
|
||||
await this._addGroupMember(g.id);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
for (const c of userHits) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'groups-modal__add-item';
|
||||
item.tabIndex = 0;
|
||||
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
|
||||
item.addEventListener('click', async () => {
|
||||
dropdown.classList.add('hidden');
|
||||
await this._addUserMember(c.id);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
dropdown.classList.remove('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
* Inline rename form. Replaces the detail view body. Same singleton-modal
|
||||
* constraint as `_renderCreateInto` — we keep all forms inside the panel.
|
||||
* @param {GroupItem} group
|
||||
*/
|
||||
_promptRename(group) {
|
||||
if (!this._bodyEl) return;
|
||||
const root = this._bodyEl;
|
||||
root.replaceChildren();
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'groups-modal__detail-header';
|
||||
|
||||
const back = document.createElement('button');
|
||||
back.type = 'button';
|
||||
back.className = 'btn btn-ghost groups-modal__back-btn';
|
||||
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
|
||||
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
|
||||
back.addEventListener('click', () => this._renderDetailInto(root));
|
||||
header.appendChild(back);
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'groups-modal__subtitle';
|
||||
title.textContent = i18n.t('groups.edit_dialog_title', 'Rename group');
|
||||
header.appendChild(title);
|
||||
root.appendChild(header);
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'groups-modal__form';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'groups-modal__form-label';
|
||||
label.textContent = i18n.t('groups.name_label', 'Name');
|
||||
form.appendChild(label);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'groups-modal__add-input';
|
||||
input.value = group.name;
|
||||
input.autocomplete = 'off';
|
||||
label.appendChild(input);
|
||||
|
||||
const err = document.createElement('div');
|
||||
err.className = 'groups-modal__inline-error hidden';
|
||||
form.appendChild(err);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'groups-modal__form-actions';
|
||||
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn btn-ghost';
|
||||
cancel.textContent = i18n.t('actions.cancel', 'Cancel');
|
||||
cancel.addEventListener('click', () => this._renderDetailInto(root));
|
||||
|
||||
const save = document.createElement('button');
|
||||
save.type = 'button';
|
||||
save.className = 'btn btn-primary';
|
||||
save.textContent = i18n.t('actions.rename', 'Rename');
|
||||
|
||||
const submit = async () => {
|
||||
const value = input.value.trim();
|
||||
if (!value || value === group.name) {
|
||||
this._renderDetailInto(root);
|
||||
return;
|
||||
}
|
||||
save.disabled = true;
|
||||
try {
|
||||
const updated = await groups.rename(group.id, value);
|
||||
this._currentGroup = updated;
|
||||
this._renderDetailInto(root);
|
||||
} catch (e) {
|
||||
err.textContent = /** @type {Error} */ (e).message;
|
||||
err.classList.remove('hidden');
|
||||
save.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
save.addEventListener('click', submit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this._renderDetailInto(root);
|
||||
}
|
||||
});
|
||||
|
||||
actions.appendChild(cancel);
|
||||
actions.appendChild(save);
|
||||
form.appendChild(actions);
|
||||
root.appendChild(form);
|
||||
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
input.select();
|
||||
}, 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Inline confirmation form for group deletion. Requires the user to type
|
||||
* the group name (safer than DELETE keyword — name is visible above).
|
||||
* @param {GroupItem} group
|
||||
*/
|
||||
_confirmDelete(group) {
|
||||
if (!this._bodyEl) return;
|
||||
const root = this._bodyEl;
|
||||
root.replaceChildren();
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'groups-modal__detail-header';
|
||||
|
||||
const back = document.createElement('button');
|
||||
back.type = 'button';
|
||||
back.className = 'btn btn-ghost groups-modal__back-btn';
|
||||
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
|
||||
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
|
||||
back.addEventListener('click', () => this._renderDetailInto(root));
|
||||
header.appendChild(back);
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'groups-modal__subtitle';
|
||||
title.textContent = i18n.t('groups.delete_group', 'Delete group');
|
||||
header.appendChild(title);
|
||||
root.appendChild(header);
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'groups-modal__form';
|
||||
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'groups-modal__inline-error';
|
||||
warning.textContent = i18n.t('groups.delete_confirm', 'Delete the group "{name}"?').replace('{name}', group.name);
|
||||
form.appendChild(warning);
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'groups-modal__form-label';
|
||||
label.textContent = i18n.t('groups.delete_confirm_label', 'Type the group name to confirm:');
|
||||
form.appendChild(label);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'groups-modal__add-input';
|
||||
input.autocomplete = 'off';
|
||||
input.placeholder = group.name;
|
||||
label.appendChild(input);
|
||||
|
||||
const err = document.createElement('div');
|
||||
err.className = 'groups-modal__inline-error hidden';
|
||||
form.appendChild(err);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'groups-modal__form-actions';
|
||||
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn btn-ghost';
|
||||
cancel.textContent = i18n.t('actions.cancel', 'Cancel');
|
||||
cancel.addEventListener('click', () => this._renderDetailInto(root));
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn btn-danger';
|
||||
del.textContent = i18n.t('actions.delete', 'Delete');
|
||||
|
||||
const submit = async () => {
|
||||
if (input.value !== group.name) {
|
||||
err.textContent = i18n.t('groups.delete_confirm_mismatch', 'Type the group name exactly to confirm.');
|
||||
err.classList.remove('hidden');
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
del.disabled = true;
|
||||
try {
|
||||
await groups.deleteGroup(group.id);
|
||||
this._currentGroup = null;
|
||||
this._members = [];
|
||||
this._renderListInto(root);
|
||||
} catch (e) {
|
||||
err.textContent = /** @type {Error} */ (e).message;
|
||||
err.classList.remove('hidden');
|
||||
del.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
del.addEventListener('click', submit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this._renderDetailInto(root);
|
||||
}
|
||||
});
|
||||
|
||||
actions.appendChild(cancel);
|
||||
actions.appendChild(del);
|
||||
form.appendChild(actions);
|
||||
root.appendChild(form);
|
||||
|
||||
setTimeout(() => input.focus(), 0);
|
||||
},
|
||||
|
||||
/** @param {string} userId */
|
||||
async _addUserMember(userId) {
|
||||
const group = this._currentGroup;
|
||||
if (!group || !this._bodyEl) return;
|
||||
try {
|
||||
await groups.addUserMember(group.id, userId);
|
||||
this._members = await groups.listMembers(group.id);
|
||||
this._renderDetailInto(this._bodyEl);
|
||||
} catch (err) {
|
||||
this._showInlineError(/** @type {Error} */ (err).message);
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {string} groupId */
|
||||
async _addGroupMember(groupId) {
|
||||
const group = this._currentGroup;
|
||||
if (!group || !this._bodyEl) return;
|
||||
try {
|
||||
await groups.addGroupMember(group.id, groupId);
|
||||
this._members = await groups.listMembers(group.id);
|
||||
this._renderDetailInto(this._bodyEl);
|
||||
} catch (err) {
|
||||
this._showInlineError(/** @type {Error} */ (err).message);
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {GroupMemberItem} m */
|
||||
async _removeMember(m) {
|
||||
const group = this._currentGroup;
|
||||
if (!group || !this._bodyEl) return;
|
||||
try {
|
||||
if (m.kind === 'user') {
|
||||
await groups.removeUserMember(group.id, m.id);
|
||||
} else {
|
||||
await groups.removeGroupMember(group.id, m.id);
|
||||
}
|
||||
this._members = await groups.listMembers(group.id);
|
||||
this._renderDetailInto(this._bodyEl);
|
||||
} catch (err) {
|
||||
this._showInlineError(/** @type {Error} */ (err).message);
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {string} message */
|
||||
_showInlineError(message) {
|
||||
if (!this._bodyEl) return;
|
||||
const existing = this._bodyEl.querySelector('.groups-modal__inline-error');
|
||||
if (existing) existing.remove();
|
||||
const el = document.createElement('div');
|
||||
el.className = 'groups-modal__inline-error';
|
||||
el.textContent = message;
|
||||
this._bodyEl.prepend(el);
|
||||
setTimeout(() => el.remove(), 5000);
|
||||
},
|
||||
|
||||
/** @param {string} message */
|
||||
_showFatalError(message) {
|
||||
if (!this._bodyEl) return;
|
||||
this._bodyEl.replaceChildren();
|
||||
const el = document.createElement('div');
|
||||
el.className = 'groups-modal__error';
|
||||
el.textContent = message;
|
||||
this._bodyEl.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
export { groupsView };
|
||||
@@ -12,12 +12,13 @@
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { MySharesList } from '../../components/mySharesList.js';
|
||||
import { collectGroupSubjectIds, MySharesList } from '../../components/mySharesList.js';
|
||||
import { shareModal } from '../../components/shareModal.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import * as viewPrefs from '../../core/viewPrefs.js';
|
||||
import * as itemTooltip from '../../features/itemTooltip.js';
|
||||
import { grants } from '../../model/grants.js';
|
||||
import { groups } from '../../model/groups.js';
|
||||
|
||||
/** @import {FileItem, FolderItem} from '../../core/types.js' */
|
||||
|
||||
@@ -69,6 +70,14 @@ const mySharesView = {
|
||||
/** @type {MySharesList|null} */
|
||||
_component: null,
|
||||
|
||||
/**
|
||||
* Cumulative id→GroupItem map of every group subject seen so far in this
|
||||
* session. Each `_loadPage()` merges newly resolved entries so subsequent
|
||||
* pages don't re-resolve already-known groups.
|
||||
* @type {Record<string, import('../../core/types.js').GroupItem>}
|
||||
*/
|
||||
_knownGroupMeta: {},
|
||||
|
||||
/** @type {string} */
|
||||
_groupBy: '',
|
||||
|
||||
@@ -188,6 +197,24 @@ const mySharesView = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve full GroupItem records for any group-subject grants on
|
||||
// this page so lane headers and identity rows render the localised
|
||||
// name + virtual-aware icon. Cheap — one search call shared across
|
||||
// every group visible on the page.
|
||||
if (this._component) {
|
||||
const groupIds = collectGroupSubjectIds(data.items);
|
||||
if (groupIds.size > 0) {
|
||||
try {
|
||||
const resolved = await groups.resolveGroups(groupIds);
|
||||
const merged = { ...this._knownGroupMeta, ...resolved };
|
||||
this._component.setGroupMeta(merged);
|
||||
this._knownGroupMeta = merged;
|
||||
} catch (err) {
|
||||
console.warn('mySharesView: failed to resolve group names', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstPage) {
|
||||
this._component?.render(data.items, mode.viewMode);
|
||||
} else {
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "فاتح",
|
||||
"dark": "داكن",
|
||||
"auto": "مثل النظام"
|
||||
}
|
||||
},
|
||||
"manage_groups": "إدارة المجموعات"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "رابط المشاركة",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "خطأ في النقل",
|
||||
"empty_name": "لا يمكن أن يكون الاسم فارغاً",
|
||||
"name_exists": "ملف أو مجلد بهذا الاسم موجود بالفعل",
|
||||
"generic_error": "حدث خطأ"
|
||||
"generic_error": "حدث خطأ",
|
||||
"group_name_invalid": "يجب أن يتطابق اسم المجموعة مع صيغة بادئة البريد الإلكتروني (حروف، أرقام، نقطة، شرطة، شرطة سفلية؛ 1–64 حرفًا).",
|
||||
"group_cycle": "سينشئ هذا العضو مرجعًا دائريًا بين المجموعات.",
|
||||
"group_depth_exceeded": "تتجاوز عمق التعشيش الحد الأقصى المسموح (8).",
|
||||
"group_virtual_immutable": "مجموعة «Internal» تدار من قبل النظام ولا يمكن تعديلها.",
|
||||
"group_not_found": "المجموعة غير موجودة.",
|
||||
"group_name_taken": "توجد بالفعل مجموعة بهذا الاسم."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "الرئيسية"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "اليوم",
|
||||
"last7days": "آخر 7 أيام",
|
||||
"last30days": "آخر 30 يومًا"
|
||||
},
|
||||
"groups": {
|
||||
"title": "إدارة المجموعات",
|
||||
"create_button": "إنشاء مجموعة",
|
||||
"create_dialog_title": "مجموعة جديدة",
|
||||
"edit_dialog_title": "إعادة تسمية المجموعة",
|
||||
"name_label": "الاسم",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "الوصف (اختياري)",
|
||||
"members_section": "الأعضاء",
|
||||
"add_member_placeholder": "إضافة مستخدم أو مجموعة…",
|
||||
"no_members": "لا يوجد أعضاء بعد.",
|
||||
"remove_member": "إزالة",
|
||||
"delete_group": "حذف المجموعة",
|
||||
"delete_confirm": "حذف المجموعة \"{name}\"؟ سيتم إلغاء الصلاحيات المرتبطة بهذه المجموعة.",
|
||||
"empty_state": "لا توجد مجموعات بعد.",
|
||||
"load_more": "تحميل المزيد",
|
||||
"back_to_list": "رجوع",
|
||||
"loading": "جارٍ التحميل…",
|
||||
"virtual_badge": "النظام",
|
||||
"member_count_zero": "لا يوجد أعضاء",
|
||||
"member_count_one": "عضو واحد",
|
||||
"member_count_other": "{count} أعضاء",
|
||||
"delete_confirm_label": "اكتب اسم المجموعة للتأكيد:",
|
||||
"delete_confirm_mismatch": "اكتب اسم المجموعة كما هو للتأكيد.",
|
||||
"virtual_internal_name": "داخلي"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"auto": "Wie System"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Gruppen verwalten"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Link teilen",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Fehler beim Verschieben",
|
||||
"empty_name": "Der Name darf nicht leer sein",
|
||||
"name_exists": "Eine Datei oder ein Ordner mit diesem Namen existiert bereits",
|
||||
"generic_error": "Ein Fehler ist aufgetreten"
|
||||
"generic_error": "Ein Fehler ist aufgetreten",
|
||||
"group_name_invalid": "Der Gruppenname muss dem E-Mail-Präfix-Format entsprechen (Buchstaben, Ziffern, Punkt, Bindestrich, Unterstrich; 1–64 Zeichen).",
|
||||
"group_cycle": "Dieses Mitglied würde einen Gruppen-Zirkelbezug erzeugen.",
|
||||
"group_depth_exceeded": "Die Verschachtelungstiefe überschreitet das zulässige Maximum (8).",
|
||||
"group_virtual_immutable": "Die Gruppe „Internal“ wird vom System verwaltet und kann nicht geändert werden.",
|
||||
"group_not_found": "Gruppe nicht gefunden.",
|
||||
"group_name_taken": "Eine Gruppe mit diesem Namen existiert bereits."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Startseite"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Heute",
|
||||
"last7days": "Letzte 7 Tage",
|
||||
"last30days": "Letzte 30 Tage"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gruppen verwalten",
|
||||
"create_button": "Gruppe erstellen",
|
||||
"create_dialog_title": "Neue Gruppe",
|
||||
"edit_dialog_title": "Gruppe umbenennen",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "Beschreibung (optional)",
|
||||
"members_section": "Mitglieder",
|
||||
"add_member_placeholder": "Benutzer oder Gruppe hinzufügen…",
|
||||
"no_members": "Noch keine Mitglieder.",
|
||||
"remove_member": "Entfernen",
|
||||
"delete_group": "Gruppe löschen",
|
||||
"delete_confirm": "Die Gruppe „{name}\" löschen? Auf diese Gruppe verweisende Berechtigungen werden widerrufen.",
|
||||
"empty_state": "Noch keine Gruppen.",
|
||||
"load_more": "Mehr laden",
|
||||
"back_to_list": "Zurück",
|
||||
"loading": "Wird geladen…",
|
||||
"virtual_badge": "System",
|
||||
"member_count_zero": "Keine Mitglieder",
|
||||
"member_count_one": "1 Mitglied",
|
||||
"member_count_other": "{count} Mitglieder",
|
||||
"delete_confirm_label": "Tippe den Gruppennamen zur Bestätigung ein:",
|
||||
"delete_confirm_mismatch": "Tippe den Gruppennamen exakt zur Bestätigung ein.",
|
||||
"virtual_internal_name": "Intern"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"auto": "Like OS"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Manage groups"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Share Link",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Error moving",
|
||||
"empty_name": "Name cannot be empty",
|
||||
"name_exists": "A file or folder with that name already exists",
|
||||
"generic_error": "An error has occurred"
|
||||
"generic_error": "An error has occurred",
|
||||
"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.",
|
||||
"group_name_taken": "A group with this name already exists."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Home"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Today",
|
||||
"last7days": "Last 7 days",
|
||||
"last30days": "Last 30 days"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Manage groups",
|
||||
"create_button": "Create group",
|
||||
"create_dialog_title": "New group",
|
||||
"edit_dialog_title": "Rename group",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "Description (optional)",
|
||||
"members_section": "Members",
|
||||
"add_member_placeholder": "Add a user or group…",
|
||||
"no_members": "No members yet.",
|
||||
"remove_member": "Remove",
|
||||
"delete_group": "Delete group",
|
||||
"delete_confirm": "Delete the group \"{name}\"? Grants referencing this group will be revoked.",
|
||||
"empty_state": "No groups yet.",
|
||||
"load_more": "Load more",
|
||||
"back_to_list": "Back",
|
||||
"loading": "Loading…",
|
||||
"virtual_badge": "System",
|
||||
"member_count_zero": "no members",
|
||||
"member_count_one": "1 member",
|
||||
"member_count_other": "{count} members",
|
||||
"delete_confirm_label": "Type the group name to confirm:",
|
||||
"delete_confirm_mismatch": "Type the group name exactly to confirm.",
|
||||
"virtual_internal_name": "Internal"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -250,7 +250,8 @@
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"auto": "Como el sistema"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Gestionar grupos"
|
||||
},
|
||||
"files": {
|
||||
"name": "Nombre",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Error al mover",
|
||||
"empty_name": "El nombre no puede estar vacío",
|
||||
"name_exists": "Ya existe un archivo o carpeta con ese nombre",
|
||||
"generic_error": "Ha ocurrido un error"
|
||||
"generic_error": "Ha ocurrido un error",
|
||||
"group_name_invalid": "El nombre del grupo debe seguir el formato de prefijo de correo (letras, dígitos, punto, guión, guion bajo; 1–64 caracteres).",
|
||||
"group_cycle": "Este miembro creaería una referencia circular entre grupos.",
|
||||
"group_depth_exceeded": "Esta profundidad de anidamiento excede el máximo permitido (8).",
|
||||
"group_virtual_immutable": "El grupo «Internal» es gestionado por el sistema y no se puede modificar.",
|
||||
"group_not_found": "Grupo no encontrado.",
|
||||
"group_name_taken": "Ya existe un grupo con este nombre."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Inicio"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Hoy",
|
||||
"last7days": "Últimos 7 días",
|
||||
"last30days": "Últimos 30 días"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gestionar grupos",
|
||||
"create_button": "Crear grupo",
|
||||
"create_dialog_title": "Nuevo grupo",
|
||||
"edit_dialog_title": "Renombrar grupo",
|
||||
"name_label": "Nombre",
|
||||
"name_placeholder": "ingenieria",
|
||||
"description_label": "Descripción (opcional)",
|
||||
"members_section": "Miembros",
|
||||
"add_member_placeholder": "Añadir un usuario o grupo…",
|
||||
"no_members": "Aún no hay miembros.",
|
||||
"remove_member": "Eliminar",
|
||||
"delete_group": "Eliminar grupo",
|
||||
"delete_confirm": "¿Eliminar el grupo «{name}»? Se revocarán las concesiones que hagan referencia a este grupo.",
|
||||
"empty_state": "Aún no hay grupos.",
|
||||
"load_more": "Cargar más",
|
||||
"back_to_list": "Volver",
|
||||
"loading": "Cargando…",
|
||||
"virtual_badge": "Sistema",
|
||||
"member_count_zero": "Sin miembros",
|
||||
"member_count_one": "1 miembro",
|
||||
"member_count_other": "{count} miembros",
|
||||
"delete_confirm_label": "Escribe el nombre del grupo para confirmar:",
|
||||
"delete_confirm_mismatch": "Escribe el nombre del grupo exactamente para confirmar.",
|
||||
"virtual_internal_name": "Interno"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "روشن",
|
||||
"dark": "تاریک",
|
||||
"auto": "مانند سیستم"
|
||||
}
|
||||
},
|
||||
"manage_groups": "مدیریت گروهها"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "پیوند همرسانی",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "خطا در انتقال",
|
||||
"empty_name": "نام نمیتواند خالی باشد",
|
||||
"name_exists": "پرونده یا پوشهای با این نام قبلا وجود دارد",
|
||||
"generic_error": "خطایی رخ داده است"
|
||||
"generic_error": "خطایی رخ داده است",
|
||||
"group_name_invalid": "نام گروه باید با قالب پیشوند ایمیل مطابقت داشته باشد (حروف، ارقام، نقطه، خط تیره، زیرخط؛ 1–64 نویسه).",
|
||||
"group_cycle": "این عضو باعث ایجاد ارجاع چرخهای بین گروهها میشود.",
|
||||
"group_depth_exceeded": "عمق تودرتو بیش از حداکثر مجاز (8) است.",
|
||||
"group_virtual_immutable": "گروه «Internal» توسط سامانه مدیریت میشود و قابل تغییر نیست.",
|
||||
"group_not_found": "گروه پیدا نشد.",
|
||||
"group_name_taken": "گروهی با این نام پیشاز این وجود دارد."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "صفحه اصلی"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "امروز",
|
||||
"last7days": "۷ روز گذشته",
|
||||
"last30days": "۳۰ روز گذشته"
|
||||
},
|
||||
"groups": {
|
||||
"title": "مدیریت گروهها",
|
||||
"create_button": "ایجاد گروه",
|
||||
"create_dialog_title": "گروه جدید",
|
||||
"edit_dialog_title": "تغییر نام گروه",
|
||||
"name_label": "نام",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "توضیحات (اختیاری)",
|
||||
"members_section": "اعضا",
|
||||
"add_member_placeholder": "افزودن کاربر یا گروه…",
|
||||
"no_members": "هنوز عضوی وجود ندارد.",
|
||||
"remove_member": "حذف",
|
||||
"delete_group": "حذف گروه",
|
||||
"delete_confirm": "گروه «{name}» حذف شود؟ مجوزهای مرتبط با این گروه باطل خواهند شد.",
|
||||
"empty_state": "هنوز گروهی وجود ندارد.",
|
||||
"load_more": "بارگیری بیشتر",
|
||||
"back_to_list": "بازگشت",
|
||||
"loading": "در حال بارگذاری…",
|
||||
"virtual_badge": "سامانه",
|
||||
"member_count_zero": "بدون عضو",
|
||||
"member_count_one": "۱ عضو",
|
||||
"member_count_other": "{count} عضو",
|
||||
"delete_confirm_label": "نام گروه را برای تأیید وارد کنید:",
|
||||
"delete_confirm_mismatch": "نام گروه را دقیقاً برای تأیید وارد کنید.",
|
||||
"virtual_internal_name": "داخلی"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"auto": "Comme le système"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Gérer les groupes"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Lien de partage",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Erreur lors du déplacement",
|
||||
"empty_name": "Le nom ne peut pas être vide",
|
||||
"name_exists": "Un fichier ou dossier portant ce nom existe déjà",
|
||||
"generic_error": "Une erreur est survenue"
|
||||
"generic_error": "Une erreur est survenue",
|
||||
"group_name_invalid": "Le nom du groupe doit respecter le format préfixe d'email (lettres, chiffres, point, tiret, souligné ; 1–64 caractères).",
|
||||
"group_cycle": "Ce membre créerait une référence circulaire entre groupes.",
|
||||
"group_depth_exceeded": "Cette profondeur d'imbrication dépasse le maximum autorisé (8).",
|
||||
"group_virtual_immutable": "Le groupe « Internal » est géré par le système et ne peut pas être modifié.",
|
||||
"group_not_found": "Groupe introuvable.",
|
||||
"group_name_taken": "Un groupe portant ce nom existe déjà."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Accueil"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Aujourd'hui",
|
||||
"last7days": "7 derniers jours",
|
||||
"last30days": "30 derniers jours"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gérer les groupes",
|
||||
"create_button": "Créer un groupe",
|
||||
"create_dialog_title": "Nouveau groupe",
|
||||
"edit_dialog_title": "Renommer le groupe",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "ingenierie",
|
||||
"description_label": "Description (facultatif)",
|
||||
"members_section": "Membres",
|
||||
"add_member_placeholder": "Ajouter un utilisateur ou un groupe…",
|
||||
"no_members": "Aucun membre pour le moment.",
|
||||
"remove_member": "Retirer",
|
||||
"delete_group": "Supprimer le groupe",
|
||||
"delete_confirm": "Supprimer le groupe « {name} » ? Les autorisations associées à ce groupe seront révoquées.",
|
||||
"empty_state": "Aucun groupe pour le moment.",
|
||||
"load_more": "Charger plus",
|
||||
"back_to_list": "Retour",
|
||||
"loading": "Chargement…",
|
||||
"virtual_badge": "Système",
|
||||
"member_count_zero": "Aucun membre",
|
||||
"member_count_one": "1 membre",
|
||||
"member_count_other": "{count} membres",
|
||||
"delete_confirm_label": "Tapez le nom du groupe pour confirmer :",
|
||||
"delete_confirm_mismatch": "Tapez le nom du groupe exactement pour confirmer.",
|
||||
"virtual_internal_name": "Interne"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "हल्का",
|
||||
"dark": "गहरा",
|
||||
"auto": "सिस्टम जैसा"
|
||||
}
|
||||
},
|
||||
"manage_groups": "समूह प्रबंधित करें"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "शेयर लिंक",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "ले जाने में त्रुटि",
|
||||
"empty_name": "नाम खाली नहीं हो सकता",
|
||||
"name_exists": "इस नाम की फ़ाइल या फ़ोल्डर पहले से मौजूद है",
|
||||
"generic_error": "एक त्रुटि हुई है"
|
||||
"generic_error": "एक त्रुटि हुई है",
|
||||
"group_name_invalid": "समूह का नाम ईमेल उपसर्ग प्रारूप के अनुरूप होना चाहिए (अक्षर, अंक, बिंदु, डैश, अंडरस्कोर; 1–64 वर्ण).",
|
||||
"group_cycle": "यह सदस्य समूहों के बीच चक्रीय संदर्भ बनाएगा।",
|
||||
"group_depth_exceeded": "यह नेस्टिंग गहराई अनुमत अधिकतम (8) से अधिक है।",
|
||||
"group_virtual_immutable": "«Internal» समूह सिस्टम द्वारा प्रबंधित है और इसे संशोधित नहीं किया जा सकता।",
|
||||
"group_not_found": "समूह नहीं मिला।",
|
||||
"group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "होम"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "आज",
|
||||
"last7days": "पिछले 7 दिन",
|
||||
"last30days": "पिछले 30 दिन"
|
||||
},
|
||||
"groups": {
|
||||
"title": "समूह प्रबंधित करें",
|
||||
"create_button": "समूह बनाएँ",
|
||||
"create_dialog_title": "नया समूह",
|
||||
"edit_dialog_title": "समूह का नाम बदलें",
|
||||
"name_label": "नाम",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "विवरण (वैकल्पिक)",
|
||||
"members_section": "सदस्य",
|
||||
"add_member_placeholder": "उपयोगकर्ता या समूह जोड़ें…",
|
||||
"no_members": "अभी तक कोई सदस्य नहीं।",
|
||||
"remove_member": "हटाएँ",
|
||||
"delete_group": "समूह हटाएँ",
|
||||
"delete_confirm": "समूह \"{name}\" को हटाएँ? इस समूह से जुड़ी अनुमतियाँ रद्द कर दी जाएँगी।",
|
||||
"empty_state": "अभी तक कोई समूह नहीं।",
|
||||
"load_more": "और लोड करें",
|
||||
"back_to_list": "वापस",
|
||||
"loading": "लोड हो रहा है…",
|
||||
"virtual_badge": "सिस्टम",
|
||||
"member_count_zero": "कोई सदस्य नहीं",
|
||||
"member_count_one": "1 सदस्य",
|
||||
"member_count_other": "{count} सदस्य",
|
||||
"delete_confirm_label": "पुष्टि के लिए समूह का नाम लिखें:",
|
||||
"delete_confirm_mismatch": "पुष्टि के लिए समूह का नाम बिल्कुल वैसा ही लिखें।",
|
||||
"virtual_internal_name": "आंतरिक"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"auto": "Come il sistema"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Gestisci gruppi"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Link di condivisione",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Errore durante lo spostamento",
|
||||
"empty_name": "Il nome non può essere vuoto",
|
||||
"name_exists": "Un file o una cartella con quel nome esiste già",
|
||||
"generic_error": "Si è verificato un errore"
|
||||
"generic_error": "Si è verificato un errore",
|
||||
"group_name_invalid": "Il nome del gruppo deve rispettare il formato del prefisso email (lettere, cifre, punto, trattino, trattino basso; 1–64 caratteri).",
|
||||
"group_cycle": "Questo membro creerebbe un riferimento circolare tra gruppi.",
|
||||
"group_depth_exceeded": "Questa profondità di annidamento supera il massimo consentito (8).",
|
||||
"group_virtual_immutable": "Il gruppo «Internal» è gestito dal sistema e non può essere modificato.",
|
||||
"group_not_found": "Gruppo non trovato.",
|
||||
"group_name_taken": "Un gruppo con questo nome esiste già."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Home"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Oggi",
|
||||
"last7days": "Ultimi 7 giorni",
|
||||
"last30days": "Ultimi 30 giorni"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gestisci gruppi",
|
||||
"create_button": "Crea gruppo",
|
||||
"create_dialog_title": "Nuovo gruppo",
|
||||
"edit_dialog_title": "Rinomina gruppo",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "ingegneria",
|
||||
"description_label": "Descrizione (opzionale)",
|
||||
"members_section": "Membri",
|
||||
"add_member_placeholder": "Aggiungi un utente o un gruppo…",
|
||||
"no_members": "Nessun membro al momento.",
|
||||
"remove_member": "Rimuovi",
|
||||
"delete_group": "Elimina gruppo",
|
||||
"delete_confirm": "Eliminare il gruppo \"{name}\"? Le autorizzazioni che fanno riferimento a questo gruppo saranno revocate.",
|
||||
"empty_state": "Nessun gruppo al momento.",
|
||||
"load_more": "Carica altro",
|
||||
"back_to_list": "Indietro",
|
||||
"loading": "Caricamento…",
|
||||
"virtual_badge": "Sistema",
|
||||
"member_count_zero": "Nessun membro",
|
||||
"member_count_one": "1 membro",
|
||||
"member_count_other": "{count} membri",
|
||||
"delete_confirm_label": "Digita il nome del gruppo per confermare:",
|
||||
"delete_confirm_mismatch": "Digita esattamente il nome del gruppo per confermare.",
|
||||
"virtual_internal_name": "Interno"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"auto": "システムに合わせる"
|
||||
}
|
||||
},
|
||||
"manage_groups": "グループを管理"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "共有リンク",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "移動エラー",
|
||||
"empty_name": "名前を空にすることはできません",
|
||||
"name_exists": "同じ名前のファイルまたはフォルダが既に存在します",
|
||||
"generic_error": "エラーが発生しました"
|
||||
"generic_error": "エラーが発生しました",
|
||||
"group_name_invalid": "グループ名はメールプレフィックス形式に一致している必要があります(文字、数字、ドット、ダッシュ、アンダースコア;1~64文字)。",
|
||||
"group_cycle": "このメンバーはグループ間で循環参照を作成します。",
|
||||
"group_depth_exceeded": "ネストの深さが許容されている最大値(8)を超えています。",
|
||||
"group_virtual_immutable": "「Internal」グループはシステム管理であり、変更できません。",
|
||||
"group_not_found": "グループが見つかりません。",
|
||||
"group_name_taken": "この名前のグループはすでに存在します。"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "ホーム"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "今日",
|
||||
"last7days": "過去7日間",
|
||||
"last30days": "過去30日間"
|
||||
},
|
||||
"groups": {
|
||||
"title": "グループを管理",
|
||||
"create_button": "グループを作成",
|
||||
"create_dialog_title": "新規グループ",
|
||||
"edit_dialog_title": "グループ名を変更",
|
||||
"name_label": "名前",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "説明(任意)",
|
||||
"members_section": "メンバー",
|
||||
"add_member_placeholder": "ユーザーまたはグループを追加…",
|
||||
"no_members": "メンバーはまだいません。",
|
||||
"remove_member": "削除",
|
||||
"delete_group": "グループを削除",
|
||||
"delete_confirm": "グループ「{name}」を削除しますか?このグループを参照しているすべての権限が取り消されます。",
|
||||
"empty_state": "グループはまだありません。",
|
||||
"load_more": "もっと読み込む",
|
||||
"back_to_list": "戻る",
|
||||
"loading": "読み込み中…",
|
||||
"virtual_badge": "システム",
|
||||
"member_count_zero": "メンバーなし",
|
||||
"member_count_one": "1 メンバー",
|
||||
"member_count_other": "{count} メンバー",
|
||||
"delete_confirm_label": "確認のためにグループ名を入力してください:",
|
||||
"delete_confirm_mismatch": "確認のためにグループ名を正確に入力してください。",
|
||||
"virtual_internal_name": "内部"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "라이트",
|
||||
"dark": "다크",
|
||||
"auto": "시스템과 동일"
|
||||
}
|
||||
},
|
||||
"manage_groups": "그룹 관리"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "공유 링크",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "이동 오류",
|
||||
"empty_name": "이름은 비워둘 수 없습니다",
|
||||
"name_exists": "같은 이름의 파일 또는 폴더가 이미 존재합니다",
|
||||
"generic_error": "오류가 발생했습니다"
|
||||
"generic_error": "오류가 발생했습니다",
|
||||
"group_name_invalid": "그룹 이름은 이메일 접두사 형식과 일치해야 합니다(문자, 숫자, 점, 대시, 밑줄; 1–64자).",
|
||||
"group_cycle": "이 구성원은 그룹 간 순환 참조를 만들 것입니다.",
|
||||
"group_depth_exceeded": "중첩 깊이가 허용 최대값(8)을 초과합니다.",
|
||||
"group_virtual_immutable": "«Internal» 그룹은 시스템이 관리하며 수정할 수 없습니다.",
|
||||
"group_not_found": "그룹을 찾을 수 없습니다.",
|
||||
"group_name_taken": "이 이름의 그룹이 이미 존재합니다."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "홈"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "오늘",
|
||||
"last7days": "최근 7일",
|
||||
"last30days": "최근 30일"
|
||||
},
|
||||
"groups": {
|
||||
"title": "그룹 관리",
|
||||
"create_button": "그룹 생성",
|
||||
"create_dialog_title": "새 그룹",
|
||||
"edit_dialog_title": "그룹 이름 변경",
|
||||
"name_label": "이름",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "설명(선택사항)",
|
||||
"members_section": "구성원",
|
||||
"add_member_placeholder": "사용자 또는 그룹 추가…",
|
||||
"no_members": "아직 구성원이 없습니다.",
|
||||
"remove_member": "제거",
|
||||
"delete_group": "그룹 삭제",
|
||||
"delete_confirm": "\"{name}\" 그룹을 삭제하시겠습니까? 이 그룹을 참조하는 모든 권한이 해제됩니다.",
|
||||
"empty_state": "아직 그룹이 없습니다.",
|
||||
"load_more": "더 보기",
|
||||
"back_to_list": "뒤로",
|
||||
"loading": "로딩 중…",
|
||||
"virtual_badge": "시스템",
|
||||
"member_count_zero": "구성원 없음",
|
||||
"member_count_one": "구성원 1명",
|
||||
"member_count_other": "구성원 {count}명",
|
||||
"delete_confirm_label": "확인을 위해 그룹 이름을 입력하세요:",
|
||||
"delete_confirm_mismatch": "확인을 위해 그룹 이름을 정확히 입력하세요.",
|
||||
"virtual_internal_name": "내부"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Licht",
|
||||
"dark": "Donker",
|
||||
"auto": "Zoals systeem"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Groepen beheren"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Deellink",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Fout bij verplaatsen",
|
||||
"empty_name": "Naam mag niet leeg zijn",
|
||||
"name_exists": "Een bestand of map met deze naam bestaat al",
|
||||
"generic_error": "Er is een fout opgetreden"
|
||||
"generic_error": "Er is een fout opgetreden",
|
||||
"group_name_invalid": "De groepsnaam moet voldoen aan het e-mailprefix-formaat (letters, cijfers, punt, streepje, underscore; 1–64 tekens).",
|
||||
"group_cycle": "Dit lid zou een circulaire groepsverwijzing veroorzaken.",
|
||||
"group_depth_exceeded": "Deze nestdiepte overschrijdt het maximum (8).",
|
||||
"group_virtual_immutable": "De groep 'Internal' wordt door het systeem beheerd en kan niet worden gewijzigd.",
|
||||
"group_not_found": "Groep niet gevonden.",
|
||||
"group_name_taken": "Er bestaat al een groep met deze naam."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Start"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Vandaag",
|
||||
"last7days": "Afgelopen 7 dagen",
|
||||
"last30days": "Afgelopen 30 dagen"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groepen beheren",
|
||||
"create_button": "Groep maken",
|
||||
"create_dialog_title": "Nieuwe groep",
|
||||
"edit_dialog_title": "Groep hernoemen",
|
||||
"name_label": "Naam",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "Beschrijving (optioneel)",
|
||||
"members_section": "Leden",
|
||||
"add_member_placeholder": "Een gebruiker of groep toevoegen…",
|
||||
"no_members": "Nog geen leden.",
|
||||
"remove_member": "Verwijderen",
|
||||
"delete_group": "Groep verwijderen",
|
||||
"delete_confirm": "De groep \"{name}\" verwijderen? Aan deze groep gekoppelde rechten worden ingetrokken.",
|
||||
"empty_state": "Nog geen groepen.",
|
||||
"load_more": "Meer laden",
|
||||
"back_to_list": "Terug",
|
||||
"loading": "Bezig met laden…",
|
||||
"virtual_badge": "Systeem",
|
||||
"member_count_zero": "Geen leden",
|
||||
"member_count_one": "1 lid",
|
||||
"member_count_other": "{count} leden",
|
||||
"delete_confirm_label": "Typ de groepsnaam ter bevestiging:",
|
||||
"delete_confirm_mismatch": "Typ de groepsnaam exact om te bevestigen.",
|
||||
"virtual_internal_name": "Intern"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Jasny",
|
||||
"dark": "Ciemny",
|
||||
"auto": "Jak system"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Zarządzaj grupami"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Link udostępniania",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Błąd podczas przenoszenia",
|
||||
"empty_name": "Nazwa nie może być pusta",
|
||||
"name_exists": "Plik lub folder o tej nazwie już istnieje",
|
||||
"generic_error": "Wystąpił błąd"
|
||||
"generic_error": "Wystąpił błąd",
|
||||
"group_name_invalid": "Nazwa grupy musi spełniać format prefiksu e-mail (litery, cyfry, kropka, myślnik, podkreślnik; 1–64 znaków).",
|
||||
"group_cycle": "Ten członek utworzyłby cykliczne odwołanie między grupami.",
|
||||
"group_depth_exceeded": "Ta głębokość zagnieżdżenia przekracza maksymalną dozwoloną (8).",
|
||||
"group_virtual_immutable": "Grupa „Internal” jest zarządzana przez system i nie może być modyfikowana.",
|
||||
"group_not_found": "Grupa nie znaleziona.",
|
||||
"group_name_taken": "Grupa o tej nazwie już istnieje."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Strona główna"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Dzisiaj",
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
"last30days": "Ostatnie 30 dni"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Zarządzaj grupami",
|
||||
"create_button": "Utwórz grupę",
|
||||
"create_dialog_title": "Nowa grupa",
|
||||
"edit_dialog_title": "Zmień nazwę grupy",
|
||||
"name_label": "Nazwa",
|
||||
"name_placeholder": "inzynieria",
|
||||
"description_label": "Opis (opcjonalny)",
|
||||
"members_section": "Członkowie",
|
||||
"add_member_placeholder": "Dodaj użytkownika lub grupę…",
|
||||
"no_members": "Brak członków.",
|
||||
"remove_member": "Usuń",
|
||||
"delete_group": "Usuń grupę",
|
||||
"delete_confirm": "Usunąć grupę „{name}\"? Uprawnienia odwołujące się do tej grupy zostaną cofnięte.",
|
||||
"empty_state": "Brak grup.",
|
||||
"load_more": "Załaduj więcej",
|
||||
"back_to_list": "Wstecz",
|
||||
"loading": "Ładowanie…",
|
||||
"virtual_badge": "System",
|
||||
"member_count_zero": "Brak członków",
|
||||
"member_count_one": "1 członek",
|
||||
"member_count_other": "{count} członków",
|
||||
"delete_confirm_label": "Wpisz nazwę grupy, aby potwierdzić:",
|
||||
"delete_confirm_mismatch": "Wpisz nazwę grupy dokładnie, aby potwierdzić.",
|
||||
"virtual_internal_name": "Wewnętrzni"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Claro",
|
||||
"dark": "Escuro",
|
||||
"auto": "Como o sistema"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Gerenciar grupos"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Link de compartilhamento",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Erro ao mover",
|
||||
"empty_name": "O nome não pode estar vazio",
|
||||
"name_exists": "Já existe um arquivo ou pasta com esse nome",
|
||||
"generic_error": "Ocorreu um erro"
|
||||
"generic_error": "Ocorreu um erro",
|
||||
"group_name_invalid": "O nome do grupo deve seguir o formato de prefixo de e-mail (letras, dígitos, ponto, hífen, sublinhado; 1–64 caracteres).",
|
||||
"group_cycle": "Este membro criaria uma referência circular entre grupos.",
|
||||
"group_depth_exceeded": "Esta profundidade de aninhamento excede o máximo permitido (8).",
|
||||
"group_virtual_immutable": "O grupo «Internal» é gerenciado pelo sistema e não pode ser modificado.",
|
||||
"group_not_found": "Grupo não encontrado.",
|
||||
"group_name_taken": "Já existe um grupo com este nome."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Início"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Hoje",
|
||||
"last7days": "Últimos 7 dias",
|
||||
"last30days": "Últimos 30 dias"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gerenciar grupos",
|
||||
"create_button": "Criar grupo",
|
||||
"create_dialog_title": "Novo grupo",
|
||||
"edit_dialog_title": "Renomear grupo",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "engenharia",
|
||||
"description_label": "Descrição (opcional)",
|
||||
"members_section": "Membros",
|
||||
"add_member_placeholder": "Adicionar um usuário ou grupo…",
|
||||
"no_members": "Ainda não há membros.",
|
||||
"remove_member": "Remover",
|
||||
"delete_group": "Excluir grupo",
|
||||
"delete_confirm": "Excluir o grupo \"{name}\"? As concessões que referenciam este grupo serão revogadas.",
|
||||
"empty_state": "Ainda não há grupos.",
|
||||
"load_more": "Carregar mais",
|
||||
"back_to_list": "Voltar",
|
||||
"loading": "Carregando…",
|
||||
"virtual_badge": "Sistema",
|
||||
"member_count_zero": "Sem membros",
|
||||
"member_count_one": "1 membro",
|
||||
"member_count_other": "{count} membros",
|
||||
"delete_confirm_label": "Digite o nome do grupo para confirmar:",
|
||||
"delete_confirm_mismatch": "Digite o nome do grupo exatamente para confirmar.",
|
||||
"virtual_internal_name": "Interno"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
"auto": "Как в системе"
|
||||
}
|
||||
},
|
||||
"manage_groups": "Управление группами"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Ссылка для обмена",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "Ошибка перемещения",
|
||||
"empty_name": "Имя не может быть пустым",
|
||||
"name_exists": "Файл или папка с таким именем уже существует",
|
||||
"generic_error": "Произошла ошибка"
|
||||
"generic_error": "Произошла ошибка",
|
||||
"group_name_invalid": "Имя группы должно соответствовать формату префикса эл. почты (буквы, цифры, точка, дефис, подчёркивание; 1–64 символов).",
|
||||
"group_cycle": "Этот участник создаст циклическую ссылку между группами.",
|
||||
"group_depth_exceeded": "Глубина вложенности превышает допустимый максимум (8).",
|
||||
"group_virtual_immutable": "Группа «Internal» управляется системой и не может быть изменена.",
|
||||
"group_not_found": "Группа не найдена.",
|
||||
"group_name_taken": "Группа с таким именем уже существует."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Главная"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "Сегодня",
|
||||
"last7days": "Последние 7 дней",
|
||||
"last30days": "Последние 30 дней"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Управление группами",
|
||||
"create_button": "Создать группу",
|
||||
"create_dialog_title": "Новая группа",
|
||||
"edit_dialog_title": "Переименовать группу",
|
||||
"name_label": "Имя",
|
||||
"name_placeholder": "инженеры",
|
||||
"description_label": "Описание (необязательно)",
|
||||
"members_section": "Участники",
|
||||
"add_member_placeholder": "Добавить пользователя или группу…",
|
||||
"no_members": "Пока нет участников.",
|
||||
"remove_member": "Удалить",
|
||||
"delete_group": "Удалить группу",
|
||||
"delete_confirm": "Удалить группу «{name}»? Все привязанные к ней разрешения будут отозваны.",
|
||||
"empty_state": "Пока нет групп.",
|
||||
"load_more": "Загрузить ещё",
|
||||
"back_to_list": "Назад",
|
||||
"loading": "Загрузка…",
|
||||
"virtual_badge": "Системная",
|
||||
"member_count_zero": "Нет участников",
|
||||
"member_count_one": "1 участник",
|
||||
"member_count_other": "{count} участников",
|
||||
"delete_confirm_label": "Введите имя группы для подтверждения:",
|
||||
"delete_confirm_mismatch": "Введите имя группы точно для подтверждения.",
|
||||
"virtual_internal_name": "Внутренние"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,8 @@
|
||||
"light": "淺色",
|
||||
"dark": "深色",
|
||||
"auto": "跟隨系統"
|
||||
}
|
||||
},
|
||||
"manage_groups": "管理群組"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "共享連結",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "移動時出錯",
|
||||
"empty_name": "名稱不能為空",
|
||||
"name_exists": "已存在同名檔案或資料夾",
|
||||
"generic_error": "發生錯誤"
|
||||
"generic_error": "發生錯誤",
|
||||
"group_name_invalid": "群組名稱必須符合電子郵件前綴格式(字母、數字、點、連字符、下劃線;1–64 個字元)。",
|
||||
"group_cycle": "此成員會在群組之間形成循環參照。",
|
||||
"group_depth_exceeded": "嵌套深度超過允許的最大值(8)。",
|
||||
"group_virtual_immutable": "「Internal」群組由系統管理,無法修改。",
|
||||
"group_not_found": "找不到群組。",
|
||||
"group_name_taken": "已存在同名群組。"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "主頁"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "今天",
|
||||
"last7days": "近7天",
|
||||
"last30days": "近30天"
|
||||
},
|
||||
"groups": {
|
||||
"title": "管理群組",
|
||||
"create_button": "建立群組",
|
||||
"create_dialog_title": "新群組",
|
||||
"edit_dialog_title": "重新命名群組",
|
||||
"name_label": "名稱",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "描述(選填)",
|
||||
"members_section": "成員",
|
||||
"add_member_placeholder": "新增使用者或群組…",
|
||||
"no_members": "尚無成員。",
|
||||
"remove_member": "移除",
|
||||
"delete_group": "刪除群組",
|
||||
"delete_confirm": "刪除群組「{name}」?引用此群組的所有授權將被撤銷。",
|
||||
"empty_state": "尚無群組。",
|
||||
"load_more": "載入更多",
|
||||
"back_to_list": "返回",
|
||||
"loading": "載入中…",
|
||||
"virtual_badge": "系統",
|
||||
"member_count_zero": "無成員",
|
||||
"member_count_one": "1 個成員",
|
||||
"member_count_other": "{count} 個成員",
|
||||
"delete_confirm_label": "請輸入群組名稱以確認:",
|
||||
"delete_confirm_mismatch": "請準確輸入群組名稱以確認。",
|
||||
"virtual_internal_name": "內部"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -131,7 +131,8 @@
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"auto": "跟随系统"
|
||||
}
|
||||
},
|
||||
"manage_groups": "管理群组"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "共享链接",
|
||||
@@ -336,7 +337,13 @@
|
||||
"move_error": "移动时出错",
|
||||
"empty_name": "名称不能为空",
|
||||
"name_exists": "已存在同名文件或文件夹",
|
||||
"generic_error": "发生错误"
|
||||
"generic_error": "发生错误",
|
||||
"group_name_invalid": "组名必须符合邮件前缀格式(字母、数字、点、连字符、下划线;1–64个字符)。",
|
||||
"group_cycle": "此成员会在组之间形成循环引用。",
|
||||
"group_depth_exceeded": "嵌套深度超出允许的最大值(8)。",
|
||||
"group_virtual_immutable": "“Internal”组由系统管理,无法修改。",
|
||||
"group_not_found": "未找到组。",
|
||||
"group_name_taken": "同名组已存在。"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "主页"
|
||||
@@ -755,5 +762,31 @@
|
||||
"today": "今天",
|
||||
"last7days": "近7天",
|
||||
"last30days": "近30天"
|
||||
},
|
||||
"groups": {
|
||||
"title": "管理群组",
|
||||
"create_button": "创建群组",
|
||||
"create_dialog_title": "新群组",
|
||||
"edit_dialog_title": "重命名群组",
|
||||
"name_label": "名称",
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "描述(可选)",
|
||||
"members_section": "成员",
|
||||
"add_member_placeholder": "添加用户或群组…",
|
||||
"no_members": "暂无成员。",
|
||||
"remove_member": "移除",
|
||||
"delete_group": "删除群组",
|
||||
"delete_confirm": "删除群组 \"{name}\"?引用此群组的所有权限将被撤销。",
|
||||
"empty_state": "暂无群组。",
|
||||
"load_more": "加载更多",
|
||||
"back_to_list": "返回",
|
||||
"loading": "加载中…",
|
||||
"virtual_badge": "系统",
|
||||
"member_count_zero": "无成员",
|
||||
"member_count_one": "1 个成员",
|
||||
"member_count_other": "{count} 个成员",
|
||||
"delete_confirm_label": "请输入群组名称以确认:",
|
||||
"delete_confirm_mismatch": "请准确输入群组名称以确认。",
|
||||
"virtual_internal_name": "内部"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
# =============================================================
|
||||
# OxiCloud — ReBAC grant cascading through nested subject groups
|
||||
# =============================================================
|
||||
# Mirrors the comprehensive permission walk from grants.hurl Phase 2,
|
||||
# but the grant target is a *parent group* and the test user reaches
|
||||
# the resource via a chain:
|
||||
#
|
||||
# henry ∈ group B ∈ group A ←— grant lives here
|
||||
#
|
||||
# Each role tier (no-grant → viewer → editor → admin) is exercised
|
||||
# on the same engine-aware endpoints as grants.hurl Phase 2. Also
|
||||
# verifies the listing-side group expansion (the "Shared with me"
|
||||
# feed: /api/grants/incoming + /api/grants/incoming/resources) so
|
||||
# the user sees folders reached via group cascade.
|
||||
#
|
||||
# Runs after subject_groups.hurl. Self-contained user ("henry"),
|
||||
# self-contained group names ("grp-...-nested-hurl"), unique folder
|
||||
# names so the test doesn't depend on or leak external state.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login as admin (alice) + create fresh user henry.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "henry", "password": "HenryPassword1!", "email": "henry@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
henry_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "henry", "password": "HenryPassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
henry_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Build the nested-group chain henry ∈ B ⊂ A.
|
||||
#
|
||||
# A (grp-grant-parent-nested-hurl)
|
||||
# └── B (grp-grant-child-nested-hurl)
|
||||
# └── henry
|
||||
#
|
||||
# Grant is on A. The recursive CTE in `expand_user` walks
|
||||
# B → A so any grant on A applies to henry.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-grant-parent-nested-hurl", "description": "outer group A" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
group_a_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-grant-child-nested-hurl", "description": "inner group B" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
group_b_id: jsonpath "$.id"
|
||||
|
||||
# A ∋ B (B is a sub-group of A)
|
||||
POST {{base_url}}/api/groups/{{group_a_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "group_id": "{{group_b_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
# B ∋ henry
|
||||
POST {{base_url}}/api/groups/{{group_b_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{henry_user_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
# Sanity: A's direct members include B (group), not henry.
|
||||
GET {{base_url}}/api/groups/{{group_a_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].kind" contains "group"
|
||||
jsonpath "$[*].id" contains "{{group_b_id}}"
|
||||
jsonpath "$[*].id" not contains "{{henry_user_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Alice creates the test resources:
|
||||
# parent folder + child folder + JPEG (auto-thumbnailed).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "nested-perm-folder", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
perm_folder_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "nested-perm-child", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
perm_child_id: jsonpath "$.id"
|
||||
|
||||
# Use a fixture unique to this test. Sharing fixtures with grants.hurl
|
||||
# would re-dedup against blob rows whose disk files were already cleaned
|
||||
# up by that test's lifecycle phase (pre-existing dedup ref-count
|
||||
# bookkeeping bug — see thumbnail_dedup memory). A unique fixture keeps
|
||||
# this test independent of that issue.
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{alice_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/nested-groups-logo.jpg; image/jpeg
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
perm_file_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase A — Henry has NO grant (nothing on A, nothing on B, no
|
||||
# inheritance). Every engine-aware endpoint denies.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Folder reads ─────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/download
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── File reads ───────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/metadata
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── Folder mutations ─────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-attack", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-rename-attempt" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── File mutations ───────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{henry_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-file-rename" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/blue-image.png;
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── Chunked upload: cannot start session in alice's folder ──
|
||||
POST {{base_url}}/api/uploads
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"filename": "henry-chunked-attack.mp4",
|
||||
"folder_id": "{{perm_folder_id}}",
|
||||
"content_type": "video/mp4",
|
||||
"total_size": 2760653,
|
||||
"chunk_size": 3000000
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── Incoming-list expansion: nothing yet. ───────────────────
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].resource.id" not contains "{{perm_folder_id}}"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase B — Alice grants Viewer to GROUP A. Cascade goes:
|
||||
# grant(A, read) → henry (via B ⊂ A) → folder + child + file.
|
||||
# Read endpoints succeed; mutations still denied.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].permission" == "read"
|
||||
jsonpath "$[0].subject.type" == "group"
|
||||
jsonpath "$[0].subject.id" == "{{group_a_id}}"
|
||||
|
||||
# ── Read endpoints now succeed ──────────────────────────────
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].id" == "{{perm_child_id}}"
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/download
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" contains "zip"
|
||||
|
||||
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].id" == "{{perm_file_id}}"
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/metadata
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" startsWith "image/"
|
||||
|
||||
# ── Folder cascade through ltree: child also readable. ──────
|
||||
GET {{base_url}}/api/folders/{{perm_child_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
# ── Incoming-list expansion: henry now sees the folder grant
|
||||
# in his "Shared with me" feed even though the grant subject
|
||||
# is group A (not henry). This validates the listing-side
|
||||
# expansion added alongside the cascade check.
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].permission" == "read"
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.type" == "group"
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.id" == "{{group_a_id}}"
|
||||
|
||||
GET {{base_url}}/api/grants/incoming/resources?limit=50
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].resource_type" == "folder"
|
||||
jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].permissions" includes "read"
|
||||
|
||||
# ── Mutations still denied (Viewer has no Update/Create/Delete) ─
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-attack-2", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-rename-as-viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-file-rename-as-viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/blue-image.png;
|
||||
|
||||
HTTP 404
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{henry_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# Viewer cannot start a chunked upload (no Create grant).
|
||||
POST {{base_url}}/api/uploads
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"filename": "viewer-chunked-attempt.mp4",
|
||||
"folder_id": "{{perm_folder_id}}",
|
||||
"content_type": "video/mp4",
|
||||
"total_size": 2760653,
|
||||
"chunk_size": 3000000
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase C — Promote group A's grant to Editor (read + comment +
|
||||
# create + update). Create/Update succeed; Delete still denied.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
# Update — folder + file rename succeed.
|
||||
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "renamed-by-henry-as-editor" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-renamed-logo.jpg" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
# Thumbnail push (Update) succeeds.
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/blue-image.png;
|
||||
|
||||
HTTP 201
|
||||
|
||||
# Create — folder + file upload + chunked upload all succeed.
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-created-child", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{henry_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/uploads
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"filename": "henry-chunked-video.mp4",
|
||||
"folder_id": "{{perm_folder_id}}",
|
||||
"content_type": "video/mp4",
|
||||
"total_size": 2760653,
|
||||
"chunk_size": 3000000
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
henry_upload_id: jsonpath "$.upload_id"
|
||||
|
||||
PATCH {{base_url}}/api/uploads/{{henry_upload_id}}?chunk_index=0
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/octet-stream
|
||||
file,fixtures/free_video_over_1MB.mp4;
|
||||
|
||||
HTTP 200
|
||||
|
||||
POST {{base_url}}/api/uploads/{{henry_upload_id}}/complete
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
henry_chunked_file_id: jsonpath "$.file_id"
|
||||
|
||||
# Alice (the owner) sees the file in the folder listing.
|
||||
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{henry_chunked_file_id}}')].name" == "henry-chunked-video.mp4"
|
||||
|
||||
# Editor still cannot delete.
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase D — Promote group A's grant to Admin (all 6 permissions).
|
||||
# Delete now succeeds for henry, still flowing through B → A.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase E — Grant lives on the INNER group B (not A).
|
||||
# Same user, same chain, but the grant is one hop closer.
|
||||
# Confirms the recursive walk works for direct-membership
|
||||
# grants as well as parent-group grants.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# First demote group A to viewer so only one access_grants row remains
|
||||
# for that (subject, resource) pair, capturing the id directly from
|
||||
# the PUT response so we don't have to filter henry's incoming list.
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
folder_grant_id: jsonpath "$[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
|
||||
# Delete that single remaining grant on group A.
|
||||
DELETE {{base_url}}/api/grants/{{folder_grant_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
# Confirm access is gone.
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# New grant targeting B directly.
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_b_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.id" == "{{group_b_id}}"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase F — Lifecycle cleanup.
|
||||
# Alice (still the owner) deletes the folder; the
|
||||
# trg_cleanup_grants_folder trigger removes the group grant.
|
||||
# Then delete the groups themselves.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/trash/empty
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
# Henry's incoming list no longer contains this folder.
|
||||
# Note: the user-groups Moka cache has a 30s TTL, but the grant row
|
||||
# itself is gone (trigger fires synchronously on folder delete), so
|
||||
# the listing query — which now expands subjects to include groups
|
||||
# — won't find anything to match on, cache hit or miss.
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].resource.id" not contains "{{perm_folder_id}}"
|
||||
|
||||
# Delete the groups.
|
||||
DELETE {{base_url}}/api/groups/{{group_a_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/groups/{{group_b_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
GET {{base_url}}/api/groups/{{group_a_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 404
|
||||
+3
-1
@@ -99,7 +99,9 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/dedup_blob_cleanup.hurl" \
|
||||
"$API_DIR/contacts.hurl" \
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl"
|
||||
"$API_DIR/grants.hurl" \
|
||||
"$API_DIR/subject_groups.hurl" \
|
||||
"$API_DIR/grants_nested_groups.hurl"
|
||||
|
||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# =============================================================
|
||||
# OxiCloud — ReBAC subject groups (POST/GET/PATCH/DELETE /api/groups)
|
||||
# =============================================================
|
||||
# Exercises:
|
||||
# • Admin-only gate on mutations (403 for non-admin)
|
||||
# • CRUD happy path (create / list / get / rename / delete)
|
||||
# • RFC 5321 name validation (400 on invalid names)
|
||||
# • Membership add/remove (users + nested groups)
|
||||
# • Cycle prevention (400 on circular reference)
|
||||
# • Authorization cascade: a user reaches a resource through group
|
||||
# membership (the headline feature)
|
||||
# • Authenticated /api/groups/search (non-admin can search)
|
||||
#
|
||||
# Runs after permissions.hurl + grants.hurl (alice = admin, bob = user).
|
||||
# Self-contained group names ("grp-...-hurl") so it doesn't depend on
|
||||
# external state and won't collide with other test files.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login as admin (alice) + create a second user (grace).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "grace", "password": "GracePassword1!", "email": "grace@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
grace_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "grace", "password": "GracePassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
grace_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Non-admin cannot create groups (403).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{grace_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-forbidden-hurl" }
|
||||
|
||||
HTTP 403
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — RFC 5321 name validation: space, leading dot, non-ASCII rejected.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp with space" }
|
||||
|
||||
HTTP 400
|
||||
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": ".leadingdot" }
|
||||
|
||||
HTTP 400
|
||||
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "équipe" }
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — CRUD happy path: create + list + get + rename.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-engineers-hurl", "description": "engineering team" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
engineers_id: jsonpath "$.id"
|
||||
[Asserts]
|
||||
jsonpath "$.name" == "grp-engineers-hurl"
|
||||
jsonpath "$.is_virtual" == false
|
||||
jsonpath "$.member_count" == 0
|
||||
jsonpath "$.can_manage" == true
|
||||
|
||||
# Duplicate name rejected (case-insensitive via CITEXT).
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "Grp-Engineers-Hurl" }
|
||||
|
||||
HTTP 409
|
||||
|
||||
# GET by id.
|
||||
GET {{base_url}}/api/groups/{{engineers_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{engineers_id}}"
|
||||
jsonpath "$.name" == "grp-engineers-hurl"
|
||||
|
||||
# Rename.
|
||||
PATCH {{base_url}}/api/groups/{{engineers_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-engineering-hurl" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.name" == "grp-engineering-hurl"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Add grace as a member of the group.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups/{{engineers_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{grace_user_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
# GET the group again — member_count now reflects the add.
|
||||
GET {{base_url}}/api/groups/{{engineers_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.member_count" == 1
|
||||
|
||||
# List members shows grace.
|
||||
GET {{base_url}}/api/groups/{{engineers_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].kind" contains "user"
|
||||
jsonpath "$[*].id" contains "{{grace_user_id}}"
|
||||
|
||||
# Idempotency: adding the same member twice is rejected with 409.
|
||||
POST {{base_url}}/api/groups/{{engineers_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{grace_user_id}}" }
|
||||
|
||||
HTTP 409
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Cycle prevention: nest engineering inside qa, then try the loop.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-qa-hurl" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
qa_id: jsonpath "$.id"
|
||||
|
||||
# Add engineering as a member of qa (qa ∋ engineering).
|
||||
POST {{base_url}}/api/groups/{{qa_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "group_id": "{{engineers_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
# Now try the reverse — engineering ∋ qa — should be rejected as a cycle.
|
||||
POST {{base_url}}/api/groups/{{engineers_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "group_id": "{{qa_id}}" }
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Authorization cascade through the group.
|
||||
#
|
||||
# Alice creates a folder, grants read to the engineering group;
|
||||
# grace (a transitive member via engineering ⊆ qa) reaches the folder.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-shared-hurl", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
shared_folder_id: jsonpath "$.id"
|
||||
|
||||
# Grant read to the engineering group on this folder.
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{engineers_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
||||
"permissions": ["read"]
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
# Grace (a member of engineering) can now list the folder's contents.
|
||||
GET {{base_url}}/api/folders/{{shared_folder_id}}
|
||||
Authorization: Bearer {{grace_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Remove grace from engineering, then re-check access (after cache TTL).
|
||||
# Note: the authz cache has a 30s TTL — Hurl tests run within seconds so
|
||||
# grace may still see the folder during the cache window. We assert the
|
||||
# membership removal succeeded; the post-TTL denial is exercised by the
|
||||
# Rust integration tests, not here (test runtime cost).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Authenticated /api/groups/search (no admin role required).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/groups/search?q=engineering
|
||||
Authorization: Bearer {{grace_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].name" contains "grp-engineering-hurl"
|
||||
|
||||
# Virtual groups (Internal, future Everyone, …) are surfaced by the
|
||||
# share-target search so they can be selected as grant subjects.
|
||||
# Ordering puts virtuals first; see `subject_group_pg_repository::list`.
|
||||
GET {{base_url}}/api/groups/search?q=Internal
|
||||
Authorization: Bearer {{grace_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].name" contains "Internal"
|
||||
jsonpath "$[?(@.name=='Internal')].is_virtual" == true
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Internal virtual group is immutable.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Internal group's well-known UUID is 00000000-0000-0000-0000-000000000001.
|
||||
POST {{base_url}}/api/groups/00000000-0000-0000-0000-000000000001/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{grace_user_id}}" }
|
||||
|
||||
HTTP 403
|
||||
|
||||
DELETE {{base_url}}/api/groups/00000000-0000-0000-0000-000000000001
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 403
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Cleanup: delete engineering (cascades to qa membership + grants).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/groups/{{qa_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
# Confirm gone.
|
||||
GET {{base_url}}/api/groups/{{engineers_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 404
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Reference in New Issue
Block a user