terp-cap-groups 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terp_cap_groups-0.1.0/.gitignore +47 -0
- terp_cap_groups-0.1.0/PKG-INFO +9 -0
- terp_cap_groups-0.1.0/pyproject.toml +33 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/__init__.py +55 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/expander.py +40 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/migrations/versions/bea53bcf1ad4_create_groups_tables.py +67 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/models.py +43 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/py.typed +0 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/router.py +123 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/schemas.py +49 -0
- terp_cap_groups-0.1.0/src/terp/capabilities/groups/service.py +174 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cap-groups
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terp groups capability — admin-managed user groups that bundle access-grant permissions.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: terp-cap-access==0.1.0
|
|
8
|
+
Requires-Dist: terp-cap-identity==0.1.0
|
|
9
|
+
Requires-Dist: terp-core==0.1.0
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-groups"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp groups capability — admin-managed user groups that bundle access-grant permissions."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
"terp-cap-access==0.1.0",
|
|
14
|
+
"terp-cap-identity==0.1.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Self-registering: the kernel discovers this ModuleSpec via the entry point,
|
|
18
|
+
# mounting the admin `groups` management router at /api/v1/groups without any
|
|
19
|
+
# composition-root edit. Importing the package also registers the access
|
|
20
|
+
# subject expander, so permissions granted to a group apply to its members.
|
|
21
|
+
[project.entry-points."terp.capabilities"]
|
|
22
|
+
groups = "terp.capabilities.groups:module"
|
|
23
|
+
|
|
24
|
+
# Owns the `user_group` / `user_group_member` tables, so it ships an independent,
|
|
25
|
+
# linear Alembic history (its own `alembic_version_groups` table), discovered by
|
|
26
|
+
# `terp migrate` via the `terp.migrations` group (ADR 0027).
|
|
27
|
+
[project.entry-points."terp.migrations"]
|
|
28
|
+
groups = "terp.capabilities.groups"
|
|
29
|
+
|
|
30
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.groups`.
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
sources = ["src"]
|
|
33
|
+
only-include = ["src/terp/capabilities/groups"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""terp.capabilities.groups — admin-managed user groups that bundle permissions.
|
|
2
|
+
|
|
3
|
+
The kernel ``Policy`` guard enforces the coarse role ladder; the access
|
|
4
|
+
capability adds per-permission grants. This capability adds the missing
|
|
5
|
+
middle: **groups** — named sets of users that hold grants *collectively*:
|
|
6
|
+
|
|
7
|
+
* persisted :class:`Group` / :class:`GroupMember` tables (flat, no nesting),
|
|
8
|
+
* an audited :class:`GroupsService` (CRUD + idempotent membership management;
|
|
9
|
+
deleting a group cascades to its memberships and its grants atomically),
|
|
10
|
+
* a **self-registering**, admin-only ``groups`` router at ``/api/v1/groups``,
|
|
11
|
+
* the access subject-expansion bridge: importing this package registers
|
|
12
|
+
:func:`~terp.capabilities.groups.expander.expand_group_memberships`, so a
|
|
13
|
+
grant whose subject is a group id is effective for every member — through
|
|
14
|
+
``require_permission`` and the kernel guard alike, with no call-site changes.
|
|
15
|
+
|
|
16
|
+
Granting to a group is an ordinary access grant (``subject_id`` = the group's
|
|
17
|
+
id). Groups carry permissions, never roles: the single-role ladder (ADR 0004)
|
|
18
|
+
is untouched.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from terp.capabilities.groups.expander import (
|
|
24
|
+
expand_group_memberships,
|
|
25
|
+
register_group_expansion,
|
|
26
|
+
)
|
|
27
|
+
from terp.capabilities.groups.models import Group, GroupMember
|
|
28
|
+
from terp.capabilities.groups.router import module, router
|
|
29
|
+
from terp.capabilities.groups.schemas import (
|
|
30
|
+
GroupCreate,
|
|
31
|
+
GroupMemberAdd,
|
|
32
|
+
GroupMemberRead,
|
|
33
|
+
GroupRead,
|
|
34
|
+
GroupUpdate,
|
|
35
|
+
)
|
|
36
|
+
from terp.capabilities.groups.service import GroupsService
|
|
37
|
+
|
|
38
|
+
# Importing the capability (entry-point discovery does) activates group-aware
|
|
39
|
+
# permission checks; without the import, access behaves exactly as before.
|
|
40
|
+
register_group_expansion()
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Group",
|
|
44
|
+
"GroupCreate",
|
|
45
|
+
"GroupMember",
|
|
46
|
+
"GroupMemberAdd",
|
|
47
|
+
"GroupMemberRead",
|
|
48
|
+
"GroupRead",
|
|
49
|
+
"GroupUpdate",
|
|
50
|
+
"GroupsService",
|
|
51
|
+
"expand_group_memberships",
|
|
52
|
+
"module",
|
|
53
|
+
"register_group_expansion",
|
|
54
|
+
"router",
|
|
55
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The groups → access bridge: expand a user to the groups they belong to.
|
|
2
|
+
|
|
3
|
+
Registered into the access capability's subject-expansion seam at package import
|
|
4
|
+
(entry-point discovery imports this package to mount the router, so installing
|
|
5
|
+
the capability activates group-aware permission checks with no composition-root
|
|
6
|
+
edit). From then on ``AccessService.has_permission`` — behind both
|
|
7
|
+
``require_permission`` and the kernel guard's ``permission_enforcer`` — checks
|
|
8
|
+
grants against the caller *and* the caller's groups.
|
|
9
|
+
|
|
10
|
+
Membership lookup is one indexed query per check; expansion is flat by design
|
|
11
|
+
(groups do not nest — a group id expands to nothing).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
|
|
19
|
+
from sqlmodel import Session
|
|
20
|
+
|
|
21
|
+
from terp.capabilities.access import register_subject_expander
|
|
22
|
+
|
|
23
|
+
from terp.capabilities.groups.service import GroupsService
|
|
24
|
+
|
|
25
|
+
_service = GroupsService()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def expand_group_memberships(
|
|
29
|
+
session: Session, subject_id: uuid.UUID
|
|
30
|
+
) -> Iterable[uuid.UUID]:
|
|
31
|
+
"""The ids of every group *subject_id* belongs to (empty for a non-member)."""
|
|
32
|
+
return _service.group_ids_for(session, subject_id)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_group_expansion() -> None:
|
|
36
|
+
"""Register the membership expander with the access capability (idempotent)."""
|
|
37
|
+
register_subject_expander(expand_group_memberships)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["expand_group_memberships", "register_group_expansion"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""create groups tables
|
|
2
|
+
|
|
3
|
+
Revision ID: bea53bcf1ad4
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-07-06 14:35:32.454331
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = 'bea53bcf1ad4'
|
|
19
|
+
down_revision: str | None = None
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
26
|
+
op.create_table('user_group',
|
|
27
|
+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
28
|
+
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
29
|
+
sa.Column('id', sa.Uuid(), nullable=False),
|
|
30
|
+
sa.Column('version', sa.Integer(), nullable=False),
|
|
31
|
+
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
|
32
|
+
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
|
|
33
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_user_group'))
|
|
34
|
+
)
|
|
35
|
+
with op.batch_alter_table('user_group', schema=None) as batch_op:
|
|
36
|
+
batch_op.create_index(batch_op.f('ix_user_group_name'), ['name'], unique=True)
|
|
37
|
+
|
|
38
|
+
op.create_table('user_group_member',
|
|
39
|
+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
40
|
+
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
41
|
+
sa.Column('id', sa.Uuid(), nullable=False),
|
|
42
|
+
sa.Column('version', sa.Integer(), nullable=False),
|
|
43
|
+
sa.Column('group_id', sa.Uuid(), nullable=False),
|
|
44
|
+
sa.Column('user_id', sa.Uuid(), nullable=False),
|
|
45
|
+
sa.ForeignKeyConstraint(['group_id'], ['user_group.id'], name=op.f('fk_user_group_member_group_id_user_group')),
|
|
46
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_user_group_member')),
|
|
47
|
+
sa.UniqueConstraint('group_id', 'user_id', name='uq_user_group_member_group_user')
|
|
48
|
+
)
|
|
49
|
+
with op.batch_alter_table('user_group_member', schema=None) as batch_op:
|
|
50
|
+
batch_op.create_index(batch_op.f('ix_user_group_member_group_id'), ['group_id'], unique=False)
|
|
51
|
+
batch_op.create_index(batch_op.f('ix_user_group_member_user_id'), ['user_id'], unique=False)
|
|
52
|
+
|
|
53
|
+
# ### end Alembic commands ###
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def downgrade() -> None:
|
|
57
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
58
|
+
with op.batch_alter_table('user_group_member', schema=None) as batch_op:
|
|
59
|
+
batch_op.drop_index(batch_op.f('ix_user_group_member_user_id'))
|
|
60
|
+
batch_op.drop_index(batch_op.f('ix_user_group_member_group_id'))
|
|
61
|
+
|
|
62
|
+
op.drop_table('user_group_member')
|
|
63
|
+
with op.batch_alter_table('user_group', schema=None) as batch_op:
|
|
64
|
+
batch_op.drop_index(batch_op.f('ix_user_group_name'))
|
|
65
|
+
|
|
66
|
+
op.drop_table('user_group')
|
|
67
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The persisted group tables — admin-managed sets of users that bundle permissions.
|
|
2
|
+
|
|
3
|
+
A :class:`Group` is a named collection; a :class:`GroupMember` is a single,
|
|
4
|
+
immutable fact: *user ``user_id`` belongs to group ``group_id``*. Groups carry
|
|
5
|
+
**permissions, not roles**: granting a permission to a group is an ordinary
|
|
6
|
+
access grant whose ``subject_id`` is the group's id (the FK-less ``Grant``
|
|
7
|
+
column anticipates exactly this), and the access capability's subject-expansion
|
|
8
|
+
seam makes it effective for every member. The kernel role ladder is untouched —
|
|
9
|
+
a group never changes anyone's rank.
|
|
10
|
+
|
|
11
|
+
``user_id`` is an FK-less UUID for the same reason ``Grant.subject_id`` is: this
|
|
12
|
+
capability must not import the higher-layer user table it references, so it stays
|
|
13
|
+
a leaf. ``group_id`` *is* a real foreign key — both tables live in this package.
|
|
14
|
+
|
|
15
|
+
The table names are ``user_group`` / ``user_group_member`` (never bare
|
|
16
|
+
``group``, a reserved SQL keyword).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import UniqueConstraint
|
|
24
|
+
from sqlmodel import Field
|
|
25
|
+
|
|
26
|
+
from terp.core import BaseTable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Group(BaseTable, table=True):
|
|
30
|
+
__tablename__ = "user_group"
|
|
31
|
+
|
|
32
|
+
name: str = Field(max_length=200, unique=True, index=True)
|
|
33
|
+
description: str = Field(default="", max_length=500)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GroupMember(BaseTable, table=True):
|
|
37
|
+
__tablename__ = "user_group_member"
|
|
38
|
+
__table_args__ = (
|
|
39
|
+
UniqueConstraint("group_id", "user_id", name="uq_user_group_member_group_user"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
group_id: uuid.UUID = Field(foreign_key="user_group.id", index=True)
|
|
43
|
+
user_id: uuid.UUID = Field(index=True)
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Admin ``groups`` router + the discoverable ``ModuleSpec``.
|
|
2
|
+
|
|
3
|
+
**Admin-only** (``Policy`` requires ``ADMIN``): managing who belongs to which
|
|
4
|
+
permission-bundling group is itself a privileged action, exactly like managing
|
|
5
|
+
grants. Exposed as ``module`` so the kernel's entry-point discovery mounts it at
|
|
6
|
+
``/api/v1/groups`` with no composition-root edit.
|
|
7
|
+
|
|
8
|
+
Granting a permission *to a group* is not done here — it is an ordinary access
|
|
9
|
+
grant (``POST /api/v1/access/grants``) whose ``subject_id`` is the group's id;
|
|
10
|
+
this router manages the groups and their memberships.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter
|
|
18
|
+
|
|
19
|
+
from terp.core import ModuleSpec, Page, PaginationDep, Policy, Roles, SessionDep
|
|
20
|
+
|
|
21
|
+
from terp.capabilities.groups.models import Group
|
|
22
|
+
from terp.capabilities.groups.schemas import (
|
|
23
|
+
GroupCreate,
|
|
24
|
+
GroupMemberAdd,
|
|
25
|
+
GroupMemberRead,
|
|
26
|
+
GroupRead,
|
|
27
|
+
GroupUpdate,
|
|
28
|
+
)
|
|
29
|
+
from terp.capabilities.groups.service import GroupsService
|
|
30
|
+
|
|
31
|
+
router = APIRouter(tags=["groups"])
|
|
32
|
+
_service = GroupsService()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read(group: Group, member_count: int) -> GroupRead:
|
|
36
|
+
return GroupRead(
|
|
37
|
+
id=group.id,
|
|
38
|
+
name=group.name,
|
|
39
|
+
description=group.description,
|
|
40
|
+
member_count=member_count,
|
|
41
|
+
version=group.version,
|
|
42
|
+
created_at=group.created_at,
|
|
43
|
+
updated_at=group.updated_at,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/", response_model=Page[GroupRead])
|
|
48
|
+
def list_groups(session: SessionDep, pagination: PaginationDep) -> Page[GroupRead]:
|
|
49
|
+
rows, total = _service.list(session, skip=pagination.skip, limit=pagination.limit)
|
|
50
|
+
counts = _service.member_counts(session, [row.id for row in rows])
|
|
51
|
+
return Page[GroupRead].of(
|
|
52
|
+
[_read(row, counts.get(row.id, 0)) for row in rows], total, pagination
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.post("/", response_model=GroupRead, status_code=201)
|
|
57
|
+
def create_group(payload: GroupCreate, session: SessionDep) -> GroupRead:
|
|
58
|
+
return _read(_service.create(session, payload), 0)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/{group_id}", response_model=GroupRead)
|
|
62
|
+
def get_group(group_id: uuid.UUID, session: SessionDep) -> GroupRead:
|
|
63
|
+
group = _service.get(session, group_id)
|
|
64
|
+
counts = _service.member_counts(session, [group.id])
|
|
65
|
+
return _read(group, counts.get(group.id, 0))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.patch("/{group_id}", response_model=GroupRead)
|
|
69
|
+
def update_group(
|
|
70
|
+
group_id: uuid.UUID, payload: GroupUpdate, session: SessionDep
|
|
71
|
+
) -> GroupRead:
|
|
72
|
+
group = _service.update(session, group_id, payload)
|
|
73
|
+
counts = _service.member_counts(session, [group.id])
|
|
74
|
+
return _read(group, counts.get(group.id, 0))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.delete("/{group_id}", status_code=204)
|
|
78
|
+
def delete_group(group_id: uuid.UUID, session: SessionDep) -> None:
|
|
79
|
+
_service.delete(session, group_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/{group_id}/members", response_model=Page[GroupMemberRead])
|
|
83
|
+
def list_members(
|
|
84
|
+
group_id: uuid.UUID, session: SessionDep, pagination: PaginationDep
|
|
85
|
+
) -> Page[GroupMemberRead]:
|
|
86
|
+
rows, total = _service.members_for(
|
|
87
|
+
session, group_id, skip=pagination.skip, limit=pagination.limit
|
|
88
|
+
)
|
|
89
|
+
emails = _service.member_emails(session, [row.user_id for row in rows])
|
|
90
|
+
items = [
|
|
91
|
+
GroupMemberRead(
|
|
92
|
+
id=row.id,
|
|
93
|
+
group_id=row.group_id,
|
|
94
|
+
user_id=row.user_id,
|
|
95
|
+
email=emails.get(row.user_id),
|
|
96
|
+
created_at=row.created_at,
|
|
97
|
+
)
|
|
98
|
+
for row in rows
|
|
99
|
+
]
|
|
100
|
+
return Page[GroupMemberRead].of(items, total, pagination)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post("/{group_id}/members", response_model=GroupMemberRead, status_code=201)
|
|
104
|
+
def add_member(
|
|
105
|
+
group_id: uuid.UUID, payload: GroupMemberAdd, session: SessionDep
|
|
106
|
+
) -> GroupMemberRead:
|
|
107
|
+
return GroupMemberRead.model_validate(
|
|
108
|
+
_service.add_member(session, group_id, payload.user_id)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.delete("/{group_id}/members/{user_id}", status_code=204)
|
|
113
|
+
def remove_member(
|
|
114
|
+
group_id: uuid.UUID, user_id: uuid.UUID, session: SessionDep
|
|
115
|
+
) -> None:
|
|
116
|
+
_service.remove_member(session, group_id, user_id)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
module = ModuleSpec(
|
|
120
|
+
name="groups",
|
|
121
|
+
router=router,
|
|
122
|
+
policy=Policy(read_role=Roles.ADMIN, write_role=Roles.ADMIN),
|
|
123
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Group DTOs — the admin surface's request / response shapes.
|
|
2
|
+
|
|
3
|
+
``GroupRead`` carries a live ``member_count`` so the admin overview can show
|
|
4
|
+
group sizes without N+1 member listings. Memberships are immutable rows
|
|
5
|
+
(add / remove, never edited), so there is no member ``*Update`` surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from sqlmodel import Field
|
|
14
|
+
|
|
15
|
+
from terp.core import BaseSchema, BaseUpdateSchema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GroupCreate(BaseSchema):
|
|
19
|
+
name: str = Field(max_length=200)
|
|
20
|
+
description: str = Field(default="", max_length=500)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GroupUpdate(BaseUpdateSchema):
|
|
24
|
+
name: str | None = Field(default=None, max_length=200)
|
|
25
|
+
description: str | None = Field(default=None, max_length=500)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GroupRead(BaseSchema):
|
|
29
|
+
id: uuid.UUID
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
member_count: int
|
|
33
|
+
version: int
|
|
34
|
+
created_at: datetime.datetime
|
|
35
|
+
updated_at: datetime.datetime
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GroupMemberAdd(BaseSchema):
|
|
39
|
+
user_id: uuid.UUID
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GroupMemberRead(BaseSchema):
|
|
43
|
+
id: uuid.UUID
|
|
44
|
+
group_id: uuid.UUID
|
|
45
|
+
user_id: uuid.UUID
|
|
46
|
+
# Resolved from the identity store when the member listing is served (one query
|
|
47
|
+
# per page); None when the account no longer exists (user_id is FK-less).
|
|
48
|
+
email: str | None = None
|
|
49
|
+
created_at: datetime.datetime
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Groups service — audited group CRUD + membership management.
|
|
2
|
+
|
|
3
|
+
Everything rides the kernel ``BaseService`` chokepoints, so every mutation is
|
|
4
|
+
audited and atomic: memberships are managed through a private member service
|
|
5
|
+
(``_save`` / ``_remove``), and deleting a group cascades — inside the *same*
|
|
6
|
+
write unit (ADR 0038) — to its membership rows and to the access grants naming
|
|
7
|
+
the group as subject, so no orphan grant keeps authorizing former members.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from sqlmodel import Session, col, func, select
|
|
15
|
+
|
|
16
|
+
from terp.core import AuditAction, BaseService, BaseUpdateSchema, NotFoundError
|
|
17
|
+
|
|
18
|
+
from terp.capabilities.access import AccessService
|
|
19
|
+
from terp.capabilities.identity import User
|
|
20
|
+
|
|
21
|
+
from terp.capabilities.groups.models import Group, GroupMember
|
|
22
|
+
from terp.capabilities.groups.schemas import GroupCreate, GroupMemberAdd, GroupUpdate
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _GroupMemberUpdate(BaseUpdateSchema):
|
|
26
|
+
"""Memberships are immutable (group + user) — nothing is updatable.
|
|
27
|
+
|
|
28
|
+
Present only to satisfy ``BaseService``'s ``UpdateT`` type parameter; the
|
|
29
|
+
router never exposes a member update route.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _MembersService(BaseService[GroupMember, GroupMemberAdd, _GroupMemberUpdate]):
|
|
34
|
+
"""Internal audited chokepoint for membership rows (not part of the public API)."""
|
|
35
|
+
|
|
36
|
+
model = GroupMember
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GroupsService(BaseService[Group, GroupCreate, GroupUpdate]):
|
|
40
|
+
model = Group
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._members = _MembersService()
|
|
44
|
+
|
|
45
|
+
# -- membership -------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _find_member(
|
|
48
|
+
self, session: Session, group_id: uuid.UUID, user_id: uuid.UUID
|
|
49
|
+
) -> GroupMember | None:
|
|
50
|
+
return session.exec(
|
|
51
|
+
self._members.base_query().where(
|
|
52
|
+
GroupMember.group_id == group_id, GroupMember.user_id == user_id
|
|
53
|
+
)
|
|
54
|
+
).first()
|
|
55
|
+
|
|
56
|
+
def add_member(
|
|
57
|
+
self, session: Session, group_id: uuid.UUID, user_id: uuid.UUID
|
|
58
|
+
) -> GroupMember:
|
|
59
|
+
"""Add *user_id* to the group (idempotent: re-adding returns the existing row)."""
|
|
60
|
+
self.get(session, group_id) # 404 before any write when the group is gone
|
|
61
|
+
existing = self._find_member(session, group_id, user_id)
|
|
62
|
+
if existing is not None:
|
|
63
|
+
return existing
|
|
64
|
+
# Membership changes effective permissions — an audit-sensitive change, so
|
|
65
|
+
# it routes through the audited chokepoint like a grant does.
|
|
66
|
+
entity = GroupMember(group_id=group_id, user_id=user_id)
|
|
67
|
+
return self._members._save(session, entity, AuditAction.CREATED)
|
|
68
|
+
|
|
69
|
+
def remove_member(
|
|
70
|
+
self, session: Session, group_id: uuid.UUID, user_id: uuid.UUID
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Remove *user_id* from the group; unknown group or non-member is a 404."""
|
|
73
|
+
self.get(session, group_id)
|
|
74
|
+
existing = self._find_member(session, group_id, user_id)
|
|
75
|
+
if existing is None:
|
|
76
|
+
raise NotFoundError()
|
|
77
|
+
self._members._remove(session, existing)
|
|
78
|
+
|
|
79
|
+
def members_for(
|
|
80
|
+
self, session: Session, group_id: uuid.UUID, *, skip: int, limit: int
|
|
81
|
+
) -> tuple[list[GroupMember], int]:
|
|
82
|
+
"""Paginated membership rows of one group (admin listing)."""
|
|
83
|
+
self.get(session, group_id)
|
|
84
|
+
return self._members._paginate(
|
|
85
|
+
session,
|
|
86
|
+
self._members.base_query().where(GroupMember.group_id == group_id),
|
|
87
|
+
skip=skip,
|
|
88
|
+
limit=limit,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def group_ids_for(self, session: Session, user_id: uuid.UUID) -> set[uuid.UUID]:
|
|
92
|
+
"""The ids of every group *user_id* belongs to (the subject-expansion source)."""
|
|
93
|
+
rows = session.exec(
|
|
94
|
+
select(GroupMember.group_id).where(GroupMember.user_id == user_id)
|
|
95
|
+
).all()
|
|
96
|
+
return set(rows)
|
|
97
|
+
|
|
98
|
+
def member_emails(
|
|
99
|
+
self, session: Session, user_ids: list[uuid.UUID]
|
|
100
|
+
) -> dict[uuid.UUID, str]:
|
|
101
|
+
"""Resolve member user ids to emails in one query (page-sized input).
|
|
102
|
+
|
|
103
|
+
Backs the member listing's ``email`` enrichment: the UI shows accounts,
|
|
104
|
+
not UUIDs, without holding a client-side directory. A vanished account
|
|
105
|
+
(``user_id`` is FK-less) is simply absent from the map.
|
|
106
|
+
"""
|
|
107
|
+
if not user_ids:
|
|
108
|
+
return {}
|
|
109
|
+
rows = session.exec(
|
|
110
|
+
select(User.id, User.email).where(col(User.id).in_(user_ids))
|
|
111
|
+
).all()
|
|
112
|
+
return dict(rows)
|
|
113
|
+
|
|
114
|
+
# -- read helpers ------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def member_counts(
|
|
117
|
+
self, session: Session, group_ids: list[uuid.UUID]
|
|
118
|
+
) -> dict[uuid.UUID, int]:
|
|
119
|
+
"""Member totals for *group_ids* in one grouped query (no N+1 in listings)."""
|
|
120
|
+
if not group_ids:
|
|
121
|
+
return {}
|
|
122
|
+
rows = session.exec(
|
|
123
|
+
select(GroupMember.group_id, func.count())
|
|
124
|
+
.where(col(GroupMember.group_id).in_(group_ids))
|
|
125
|
+
.group_by(GroupMember.group_id)
|
|
126
|
+
).all()
|
|
127
|
+
return dict(rows)
|
|
128
|
+
|
|
129
|
+
# -- cascade -----------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _after_write(self, session: Session, entity: Group, action: AuditAction) -> None:
|
|
132
|
+
"""Deleting a group cascades to its memberships and its grants, atomically.
|
|
133
|
+
|
|
134
|
+
Runs inside the same write unit as the group's own ``DELETED`` record
|
|
135
|
+
(ADR 0038): the nested audited removals join the transaction and flush
|
|
136
|
+
before the group row is deleted, so the FK holds and a failure anywhere
|
|
137
|
+
rolls back the whole cascade. Grants naming the group as subject are
|
|
138
|
+
revoked through the access service, so a deleted group cannot keep
|
|
139
|
+
authorizing its former members via a dangling subject id.
|
|
140
|
+
"""
|
|
141
|
+
super()._after_write(session, entity, action)
|
|
142
|
+
if action is not AuditAction.DELETED:
|
|
143
|
+
return
|
|
144
|
+
# Drain in batches until nothing remains: nested audited removals flush into
|
|
145
|
+
# this same transaction, so each pass sees the prior pass's deletes. No size
|
|
146
|
+
# cliff — a group of any size cascades completely or rolls back completely.
|
|
147
|
+
while True:
|
|
148
|
+
members, _total = self._members._paginate(
|
|
149
|
+
session,
|
|
150
|
+
self._members.base_query().where(GroupMember.group_id == entity.id),
|
|
151
|
+
skip=0,
|
|
152
|
+
limit=_CASCADE_BATCH,
|
|
153
|
+
)
|
|
154
|
+
if not members:
|
|
155
|
+
break
|
|
156
|
+
for member in members:
|
|
157
|
+
self._members._remove(session, member)
|
|
158
|
+
access = AccessService()
|
|
159
|
+
while True:
|
|
160
|
+
grants, _grant_total = access.list_for(
|
|
161
|
+
session, entity.id, skip=0, limit=_CASCADE_BATCH
|
|
162
|
+
)
|
|
163
|
+
if not grants:
|
|
164
|
+
break
|
|
165
|
+
for grant in grants:
|
|
166
|
+
access.delete(session, grant.id)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Rows fetched per cascade pass (the loop above drains every pass until empty,
|
|
170
|
+
# so this bounds memory per pass — never the cascade's total size).
|
|
171
|
+
_CASCADE_BATCH = 1_000
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__ = ["GroupsService"]
|