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.
- terp_cap_access-0.1.0/.gitignore +47 -0
- terp_cap_access-0.1.0/PKG-INFO +7 -0
- terp_cap_access-0.1.0/pyproject.toml +29 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/__init__.py +45 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/deps.py +78 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/expansion.py +67 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/migrations/versions/71a140f9930e_create_access_tables.py +50 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/models.py +33 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/py.typed +0 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/router.py +53 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/schemas.py +32 -0
- terp_cap_access-0.1.0/src/terp/capabilities/access/service.py +97 -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,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)
|
|
File without changes
|
|
@@ -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"]
|