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,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;
|
||||
Reference in New Issue
Block a user