terp-cap-access 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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-access
3
+ Version: 0.1.0
4
+ Summary: Terp access capability — RBAC permission grants + a fail-closed require_permission dependency.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-access"
7
+ version = "0.1.0"
8
+ description = "Terp access capability — RBAC permission grants + a fail-closed require_permission dependency."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ ]
14
+
15
+ # Self-registering: the kernel discovers this ModuleSpec via the entry point,
16
+ # mounting the admin `access` (grants) router without any composition-root edit.
17
+ [project.entry-points."terp.capabilities"]
18
+ access = "terp.capabilities.access:module"
19
+
20
+ # Owns the `access_grant` table, so it ships an independent, linear Alembic history
21
+ # (its own `alembic_version_access` table), discovered by `terp migrate` via the
22
+ # `terp.migrations` group (ADR 0027).
23
+ [project.entry-points."terp.migrations"]
24
+ access = "terp.capabilities.access"
25
+
26
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.access`.
27
+ [tool.hatch.build.targets.wheel]
28
+ sources = ["src"]
29
+ only-include = ["src/terp/capabilities/access"]
@@ -0,0 +1,45 @@
1
+ """terp.capabilities.access — RBAC permission grants + ``require_permission``.
2
+
3
+ The fourth opt-in capability and the remaining base-profile authorization piece.
4
+ The kernel ``Policy`` guard enforces the coarse, global role ladder; this
5
+ capability adds **fine-grained, per-permission** authorization on top:
6
+
7
+ * a persisted :class:`Grant` (subject ↦ open, app-defined permission token),
8
+ * an :class:`AccessService` (idempotent ``grant`` / ``revoke`` / ``has_permission``),
9
+ * a fail-closed :func:`require_permission` dependency modules mount on a route,
10
+ * a **self-registering**, admin-only ``access`` router to administer grants.
11
+
12
+ It depends only on ``terp-core``: it reads the caller through the kernel's public
13
+ ``get_principal`` seam (which ``create_app`` points at the configured provider),
14
+ so it never imports the auth capability.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from terp.capabilities.access.deps import enforce_permission, require_permission
20
+ from terp.capabilities.access.expansion import (
21
+ SubjectExpander,
22
+ register_subject_expander,
23
+ reset_subject_expanders,
24
+ subject_ids_for,
25
+ )
26
+ from terp.capabilities.access.models import Grant
27
+ from terp.capabilities.access.router import module, router
28
+ from terp.capabilities.access.schemas import GrantCreate, GrantRead, GrantUpdate
29
+ from terp.capabilities.access.service import AccessService
30
+
31
+ __all__ = [
32
+ "AccessService",
33
+ "Grant",
34
+ "GrantCreate",
35
+ "GrantRead",
36
+ "GrantUpdate",
37
+ "SubjectExpander",
38
+ "enforce_permission",
39
+ "module",
40
+ "register_subject_expander",
41
+ "require_permission",
42
+ "reset_subject_expanders",
43
+ "router",
44
+ "subject_ids_for",
45
+ ]
@@ -0,0 +1,78 @@
1
+ """``require_permission`` — a fail-closed, fine-grained authorization dependency.
2
+
3
+ The module-level ``Policy`` guard enforces the coarse role ladder; this dependency
4
+ enforces an **open, app-defined permission** on a single route or router, on top
5
+ of (or instead of) a role. It is the runtime half of access's two-layer control:
6
+ deny-by-default — an unauthenticated caller gets 401, an authenticated caller
7
+ without the grant gets 403::
8
+
9
+ from terp.capabilities.access import require_permission
10
+
11
+ @router.post("/export", dependencies=[Depends(require_permission("reports:export"))])
12
+ def export(...): ...
13
+
14
+ It reads the caller through the kernel's public ``get_principal`` seam, which
15
+ ``create_app`` points at the configured provider (e.g. the auth capability), so
16
+ this capability never imports auth.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import uuid
22
+ from collections.abc import Callable
23
+
24
+ from fastapi import Depends
25
+ from sqlmodel import Session
26
+
27
+ from terp.core import (
28
+ AuthenticationError,
29
+ PermissionDeniedError,
30
+ Permission,
31
+ Principal,
32
+ SessionDep,
33
+ get_principal,
34
+ )
35
+
36
+ from terp.capabilities.access.service import AccessService
37
+
38
+ _service = AccessService()
39
+
40
+
41
+ def require_permission(permission: str | Permission) -> Callable[..., None]:
42
+ """Build a dependency requiring *permission* (deny-by-default).
43
+
44
+ ``Permission`` is the Phase-A typed path. ``str`` remains for compatibility
45
+ until the architecture rule can guide modules to the control plane.
46
+ """
47
+
48
+ permission_name = permission.name if isinstance(permission, Permission) else permission
49
+
50
+ def dependency(
51
+ session: SessionDep,
52
+ principal: Principal | None = Depends(get_principal),
53
+ ) -> None:
54
+ if principal is None:
55
+ raise AuthenticationError()
56
+ if not _service.has_permission(session, principal.id, permission_name):
57
+ raise PermissionDeniedError()
58
+
59
+ # Introspection marker (never a control): `terp inspect access` reads this to
60
+ # surface route-level permission requirements in the access graph.
61
+ dependency.__terp_required_permission__ = permission_name # type: ignore[attr-defined]
62
+ return dependency
63
+
64
+
65
+ def enforce_permission(
66
+ session: Session, subject_id: uuid.UUID, permission_name: str
67
+ ) -> bool:
68
+ """Per-subject permission check for the kernel guard (the ``create_app`` seam).
69
+
70
+ Pass to ``create_app(permission_enforcer=enforce_permission)`` so a ``Policy``
71
+ that requires a ``Permission`` is enforced as a real grant (deny-by-default),
72
+ never silently degraded to the permission's role rank. Returns whether
73
+ *subject_id* currently holds *permission_name*.
74
+ """
75
+ return _service.has_permission(session, subject_id, permission_name)
76
+
77
+
78
+ __all__ = ["enforce_permission", "require_permission"]
@@ -0,0 +1,67 @@
1
+ """Subject expansion — the seam that lets grants apply to *collections* of subjects.
2
+
3
+ A :class:`~terp.capabilities.access.models.Grant` names a single ``subject_id``.
4
+ That subject is usually a user, but the column is FK-less **by design**: a grant
5
+ can just as well name a *group* of users (or any future principal-like subject).
6
+ This module is the seam that makes such indirect grants effective without the
7
+ access capability knowing who provides them:
8
+
9
+ * a higher-layer capability (e.g. ``terp-cap-groups``) **registers** a
10
+ :data:`SubjectExpander` — a callable mapping one subject id to the extra
11
+ subject ids it speaks for (a user's group ids);
12
+ * :meth:`~terp.capabilities.access.service.AccessService.has_permission` (the
13
+ single hot path behind both ``require_permission`` and the kernel guard's
14
+ ``permission_enforcer``) checks grants against the **expanded** subject set.
15
+
16
+ The plug-in direction mirrors the kernel's scope-predicate registry (ADR 0017):
17
+ the lower layer owns the registry and the check; the higher layer plugs in at
18
+ import time; the lower layer never imports the higher. With no expander
19
+ registered the set is exactly ``{subject_id}`` — the behaviour before this seam
20
+ existed. An expander that raises propagates: the guarded request fails closed
21
+ (500, no grant assumed) rather than silently narrowing to direct grants.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import uuid
27
+ from collections.abc import Callable, Iterable
28
+
29
+ from sqlmodel import Session
30
+
31
+ # Maps one subject to the additional subject ids whose grants it inherits
32
+ # (e.g. a user -> the ids of the groups the user belongs to).
33
+ SubjectExpander = Callable[[Session, uuid.UUID], Iterable[uuid.UUID]]
34
+
35
+ _expanders: list[SubjectExpander] = []
36
+
37
+
38
+ def register_subject_expander(expander: SubjectExpander) -> None:
39
+ """Register *expander* (idempotent: re-registering the same callable is a no-op).
40
+
41
+ Called at import time by the providing capability (the groups capability
42
+ registers its membership expander when its package is imported by entry-point
43
+ discovery), so installing the capability is all it takes.
44
+ """
45
+ if expander not in _expanders:
46
+ _expanders.append(expander)
47
+
48
+
49
+ def reset_subject_expanders() -> None:
50
+ """Clear the registry (test isolation for suites that register a throwaway expander)."""
51
+ _expanders.clear()
52
+
53
+
54
+ def subject_ids_for(session: Session, subject_id: uuid.UUID) -> set[uuid.UUID]:
55
+ """The full subject set whose grants *subject_id* holds: itself + every expansion."""
56
+ subjects = {subject_id}
57
+ for expander in _expanders:
58
+ subjects.update(expander(session, subject_id))
59
+ return subjects
60
+
61
+
62
+ __all__ = [
63
+ "SubjectExpander",
64
+ "register_subject_expander",
65
+ "reset_subject_expanders",
66
+ "subject_ids_for",
67
+ ]
@@ -0,0 +1,50 @@
1
+ """create access tables
2
+
3
+ Revision ID: 71a140f9930e
4
+ Revises:
5
+ Create Date: 2026-06-26 20:38:59.309924
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 = '71a140f9930e'
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('access_grant',
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('subject_id', sa.Uuid(), nullable=False),
32
+ sa.Column('permission', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
33
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_access_grant')),
34
+ sa.UniqueConstraint('subject_id', 'permission', name='uq_access_grant_subject_permission')
35
+ )
36
+ with op.batch_alter_table('access_grant', schema=None) as batch_op:
37
+ batch_op.create_index(batch_op.f('ix_access_grant_permission'), ['permission'], unique=False)
38
+ batch_op.create_index(batch_op.f('ix_access_grant_subject_id'), ['subject_id'], unique=False)
39
+
40
+ # ### end Alembic commands ###
41
+
42
+
43
+ def downgrade() -> None:
44
+ # ### commands auto generated by Alembic - please adjust! ###
45
+ with op.batch_alter_table('access_grant', schema=None) as batch_op:
46
+ batch_op.drop_index(batch_op.f('ix_access_grant_subject_id'))
47
+ batch_op.drop_index(batch_op.f('ix_access_grant_permission'))
48
+
49
+ op.drop_table('access_grant')
50
+ # ### end Alembic commands ###
@@ -0,0 +1,33 @@
1
+ """The persisted access-grant table (RBAC permission grants).
2
+
3
+ A :class:`Grant` is a single, immutable fact: *subject ``subject_id`` holds the
4
+ named ``permission``*. Permissions are open, app-defined string tokens (e.g.
5
+ ``"billing:write"``, ``"reports:export"``) — the capability hard-codes **no**
6
+ company module list. A composite unique constraint makes a grant idempotent: a
7
+ subject holds a given permission at most once.
8
+
9
+ ``subject_id`` is an FK-less UUID on purpose: this low-layer capability must not
10
+ import the higher-layer user table it references, so it stays a leaf the identity
11
+ and app modules can depend on (never the reverse).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+
18
+ from sqlalchemy import UniqueConstraint
19
+ from sqlmodel import Field
20
+
21
+ from terp.core import BaseTable
22
+
23
+
24
+ class Grant(BaseTable, table=True):
25
+ __tablename__ = "access_grant"
26
+ __table_args__ = (
27
+ UniqueConstraint(
28
+ "subject_id", "permission", name="uq_access_grant_subject_permission"
29
+ ),
30
+ )
31
+
32
+ subject_id: uuid.UUID = Field(index=True)
33
+ permission: str = Field(max_length=128, index=True)
@@ -0,0 +1,53 @@
1
+ """Admin ``access`` (grants) router + the discoverable ``ModuleSpec``.
2
+
3
+ **Admin-only** (``Policy`` requires ``ADMIN``): managing who holds which
4
+ permission is itself a privileged action. Exposed as ``module`` so the kernel's
5
+ entry-point discovery mounts it at ``/api/v1/access`` with no composition-root
6
+ edit. Modules then gate their own actions with ``require_permission`` against the
7
+ grants administered here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import uuid
13
+
14
+ from fastapi import APIRouter
15
+
16
+ from terp.core import ModuleSpec, Page, PaginationDep, Policy, Roles, SessionDep
17
+
18
+ from terp.capabilities.access.schemas import GrantCreate, GrantRead
19
+ from terp.capabilities.access.service import AccessService
20
+
21
+ router = APIRouter(tags=["access"])
22
+ _service = AccessService()
23
+
24
+
25
+ @router.get("/grants", response_model=Page[GrantRead])
26
+ def list_grants(
27
+ subject_id: uuid.UUID, session: SessionDep, pagination: PaginationDep
28
+ ) -> Page[GrantRead]:
29
+ rows, total = _service.list_for(
30
+ session, subject_id, skip=pagination.skip, limit=pagination.limit
31
+ )
32
+ return Page[GrantRead].of(
33
+ [GrantRead.model_validate(row) for row in rows], total, pagination
34
+ )
35
+
36
+
37
+ @router.post("/grants", response_model=GrantRead, status_code=201)
38
+ def create_grant(payload: GrantCreate, session: SessionDep) -> GrantRead:
39
+ return GrantRead.model_validate(
40
+ _service.grant(session, payload.subject_id, payload.permission)
41
+ )
42
+
43
+
44
+ @router.delete("/grants/{grant_id}", status_code=204)
45
+ def delete_grant(grant_id: uuid.UUID, session: SessionDep) -> None:
46
+ _service.delete(session, grant_id)
47
+
48
+
49
+ module = ModuleSpec(
50
+ name="access",
51
+ router=router,
52
+ policy=Policy(read_role=Roles.ADMIN, write_role=Roles.ADMIN),
53
+ )
@@ -0,0 +1,32 @@
1
+ """Access DTOs. Grants are immutable, so there is no public ``*Update`` surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import uuid
7
+
8
+ from sqlmodel import Field
9
+
10
+ from terp.core import BaseSchema, BaseUpdateSchema
11
+
12
+
13
+ class GrantCreate(BaseSchema):
14
+ subject_id: uuid.UUID
15
+ permission: str = Field(max_length=128)
16
+
17
+
18
+ class GrantUpdate(BaseUpdateSchema):
19
+ """Grants are immutable (subject + permission) — nothing is updatable.
20
+
21
+ Present only to satisfy ``BaseService``'s ``UpdateT`` type parameter; the
22
+ admin router never exposes an update route.
23
+ """
24
+
25
+
26
+ class GrantRead(BaseSchema):
27
+ id: uuid.UUID
28
+ subject_id: uuid.UUID
29
+ permission: str
30
+ version: int
31
+ created_at: datetime.datetime
32
+ updated_at: datetime.datetime
@@ -0,0 +1,97 @@
1
+ """Access service — RBAC permission grants + the effective-permission check.
2
+
3
+ Grants are immutable (subject + permission), so writes are :meth:`grant`
4
+ (idempotent) and :meth:`revoke`; reads build on the kernel ``BaseService`` so
5
+ get / list / delete-by-id come for free. :meth:`has_permission` is the hot path
6
+ the ``require_permission`` dependency calls on every guarded request; it checks
7
+ the **expanded** subject set (the caller plus whatever registered subject
8
+ expanders add — e.g. the groups capability's memberships), so a grant to a
9
+ group is effective for its members with no extra call sites.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+
16
+ from sqlmodel import Session, col, select
17
+
18
+ from terp.core import AuditAction, BaseService
19
+
20
+ from terp.capabilities.access.expansion import subject_ids_for
21
+ from terp.capabilities.access.models import Grant
22
+ from terp.capabilities.access.schemas import GrantCreate, GrantUpdate
23
+
24
+
25
+ class AccessService(BaseService[Grant, GrantCreate, GrantUpdate]):
26
+ model = Grant
27
+
28
+ def _find(
29
+ self, session: Session, subject_id: uuid.UUID, permission: str
30
+ ) -> Grant | None:
31
+ return session.exec(
32
+ select(Grant).where(
33
+ Grant.subject_id == subject_id, Grant.permission == permission
34
+ )
35
+ ).first()
36
+
37
+ def grant(self, session: Session, subject_id: uuid.UUID, permission: str) -> Grant:
38
+ """Grant *permission* to *subject_id* (idempotent: a re-grant returns the existing row)."""
39
+ existing = self._find(session, subject_id, permission)
40
+ if existing is not None:
41
+ return existing
42
+ entity = Grant(subject_id=subject_id, permission=permission)
43
+ # A grant is a security-sensitive change: route it through the audited
44
+ # chokepoint so it lands an audit record (raw session writes do not).
45
+ return self._save(session, entity, AuditAction.CREATED)
46
+
47
+ def revoke(self, session: Session, subject_id: uuid.UUID, permission: str) -> bool:
48
+ """Revoke *permission* from *subject_id*; return whether a grant was removed."""
49
+ existing = self._find(session, subject_id, permission)
50
+ if existing is None:
51
+ return False
52
+ # Revoking a permission is audit-sensitive too: go through _remove so the
53
+ # DELETED record is emitted in the same transaction.
54
+ self._remove(session, existing)
55
+ return True
56
+
57
+ def has_permission(
58
+ self, session: Session, subject_id: uuid.UUID, permission: str
59
+ ) -> bool:
60
+ """True when *subject_id* holds *permission* (the deny-by-default check).
61
+
62
+ Checks the expanded subject set: a direct grant, or a grant to any subject
63
+ a registered expander maps the caller to (e.g. a group the user belongs
64
+ to). With no expander registered this is exactly the direct-grant check.
65
+ """
66
+ subjects = subject_ids_for(session, subject_id)
67
+ return (
68
+ session.exec(
69
+ select(Grant).where(
70
+ col(Grant.subject_id).in_(subjects),
71
+ Grant.permission == permission,
72
+ )
73
+ ).first()
74
+ is not None
75
+ )
76
+
77
+ def permissions_for(self, session: Session, subject_id: uuid.UUID) -> set[str]:
78
+ """Every permission *subject_id* holds — directly or through an expanded subject."""
79
+ subjects = subject_ids_for(session, subject_id)
80
+ rows = session.exec(
81
+ select(Grant.permission).where(col(Grant.subject_id).in_(subjects))
82
+ ).all()
83
+ return set(rows)
84
+
85
+ def list_for(
86
+ self, session: Session, subject_id: uuid.UUID, *, skip: int, limit: int
87
+ ) -> tuple[list[Grant], int]:
88
+ """Paginated grants for *subject_id* (admin listing)."""
89
+ return self._paginate(
90
+ session,
91
+ self.base_query().where(Grant.subject_id == subject_id),
92
+ skip=skip,
93
+ limit=limit,
94
+ )
95
+
96
+
97
+ __all__ = ["AccessService"]