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.
@@ -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)
@@ -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"]