pkg-auth 3.0.0__py3-none-any.whl

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.
Files changed (110) hide show
  1. pkg_auth/__init__.py +15 -0
  2. pkg_auth/admin/__init__.py +35 -0
  3. pkg_auth/admin/cli.py +87 -0
  4. pkg_auth/admin/client.py +401 -0
  5. pkg_auth/admin/env.py +74 -0
  6. pkg_auth/admin/helpers.py +113 -0
  7. pkg_auth/admin/provision_client.py +86 -0
  8. pkg_auth/admin/settings.py +33 -0
  9. pkg_auth/authentication/__init__.py +33 -0
  10. pkg_auth/authentication/adapters/__init__.py +1 -0
  11. pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
  12. pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
  13. pkg_auth/authentication/application/__init__.py +1 -0
  14. pkg_auth/authentication/application/use_cases/__init__.py +1 -0
  15. pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
  16. pkg_auth/authentication/domain/__init__.py +1 -0
  17. pkg_auth/authentication/domain/entities.py +50 -0
  18. pkg_auth/authentication/domain/exceptions.py +18 -0
  19. pkg_auth/authentication/domain/ports.py +26 -0
  20. pkg_auth/authentication/domain/value_objects.py +42 -0
  21. pkg_auth/authorization/__init__.py +117 -0
  22. pkg_auth/authorization/adapters/__init__.py +1 -0
  23. pkg_auth/authorization/adapters/cache/__init__.py +32 -0
  24. pkg_auth/authorization/adapters/cache/decorators.py +181 -0
  25. pkg_auth/authorization/adapters/cache/memory.py +61 -0
  26. pkg_auth/authorization/adapters/cache/protocol.py +36 -0
  27. pkg_auth/authorization/adapters/cache/redis.py +60 -0
  28. pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
  29. pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
  30. pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
  31. pkg_auth/authorization/adapters/django_orm/models.py +226 -0
  32. pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
  33. pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
  34. pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
  35. pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
  36. pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
  37. pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
  38. pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
  39. pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
  40. pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
  41. pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
  42. pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
  43. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
  44. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
  45. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
  46. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
  47. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
  48. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
  49. pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
  50. pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
  51. pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
  52. pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
  53. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
  54. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
  55. pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
  56. pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
  57. pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
  58. pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
  59. pkg_auth/authorization/application/__init__.py +1 -0
  60. pkg_auth/authorization/application/use_cases/__init__.py +1 -0
  61. pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
  62. pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
  63. pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
  64. pkg_auth/authorization/application/use_cases/create_role.py +69 -0
  65. pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
  66. pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
  67. pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
  68. pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
  69. pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
  70. pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
  71. pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
  72. pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
  73. pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
  74. pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
  75. pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
  76. pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
  77. pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
  78. pkg_auth/authorization/application/use_cases/update_role.py +61 -0
  79. pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
  80. pkg_auth/authorization/cli/__init__.py +1 -0
  81. pkg_auth/authorization/cli/sync_catalog.py +180 -0
  82. pkg_auth/authorization/cli/sync_services.py +151 -0
  83. pkg_auth/authorization/config.py +21 -0
  84. pkg_auth/authorization/domain/__init__.py +1 -0
  85. pkg_auth/authorization/domain/entities.py +192 -0
  86. pkg_auth/authorization/domain/exceptions.py +68 -0
  87. pkg_auth/authorization/domain/ports.py +217 -0
  88. pkg_auth/authorization/domain/value_objects.py +208 -0
  89. pkg_auth/authorization/platform.py +47 -0
  90. pkg_auth/integrations/__init__.py +0 -0
  91. pkg_auth/integrations/django/__init__.py +32 -0
  92. pkg_auth/integrations/django/apps.py +10 -0
  93. pkg_auth/integrations/django/auth_context_middleware.py +105 -0
  94. pkg_auth/integrations/django/decorators.py +74 -0
  95. pkg_auth/integrations/django/install.py +136 -0
  96. pkg_auth/integrations/django/middleware.py +63 -0
  97. pkg_auth/integrations/fastapi/__init__.py +26 -0
  98. pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
  99. pkg_auth/integrations/fastapi/auth_factory.py +84 -0
  100. pkg_auth/integrations/fastapi/decorators.py +55 -0
  101. pkg_auth/integrations/fastapi/errors.py +72 -0
  102. pkg_auth/integrations/fastapi/identity_dep.py +41 -0
  103. pkg_auth/integrations/strawberry/__init__.py +20 -0
  104. pkg_auth/integrations/strawberry/auth.py +137 -0
  105. pkg_auth/integrations/strawberry/permissions.py +56 -0
  106. pkg_auth-3.0.0.dist-info/METADATA +147 -0
  107. pkg_auth-3.0.0.dist-info/RECORD +110 -0
  108. pkg_auth-3.0.0.dist-info/WHEEL +5 -0
  109. pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
  110. pkg_auth-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,117 @@
1
+ """Authorization module: full ACL on top of pkg_auth.authentication.
2
+
3
+ Public API:
4
+
5
+ Entities: User, Organization, Permission, Role, Membership, AuthContext
6
+ Value objects: UserId, OrgId, RoleId, PermissionId, RoleName, PermissionKey
7
+ Ports: UserRepository, OrganizationRepository, RoleRepository,
8
+ MembershipRepository, PermissionCatalogRepository
9
+ Exceptions: AuthorizationError, NotAMember, MissingPermission,
10
+ UnknownOrganization, UnknownUser, UnknownRole,
11
+ UserNotProvisioned
12
+
13
+ The application layer (use cases) is added in M3; SQLAlchemy / Django ORM
14
+ adapters in M4 / M6; cache layer in M5; framework integrations in M7-M9.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from .application.use_cases.register_permission_catalog import CatalogEntry
19
+ from .application.use_cases.sync_service_catalog import ServiceSpec
20
+ from .config import default_locale
21
+ from .domain.entities import (
22
+ AuthContext,
23
+ Membership,
24
+ Organization,
25
+ OrganizationService,
26
+ Permission,
27
+ Role,
28
+ Service,
29
+ User,
30
+ )
31
+ from .platform import is_platform_context
32
+ from .domain.exceptions import (
33
+ AuthorizationError,
34
+ MissingPermission,
35
+ NotAMember,
36
+ PermissionVisibilityConflict,
37
+ ServiceNotEnabled,
38
+ ServiceNotSaaSAvailable,
39
+ UnknownOrganization,
40
+ UnknownPermission,
41
+ UnknownRole,
42
+ UnknownService,
43
+ UnknownUser,
44
+ UserNotProvisioned,
45
+ )
46
+ from .domain.ports import (
47
+ MembershipRepository,
48
+ OrganizationRepository,
49
+ OrganizationServiceRepository,
50
+ PermissionCatalogRepository,
51
+ PermissionScope,
52
+ RoleRepository,
53
+ ServiceRepository,
54
+ UserRepository,
55
+ )
56
+ from .domain.value_objects import (
57
+ LocalizedText,
58
+ OrgId,
59
+ PermissionId,
60
+ PermissionKey,
61
+ PermissionVisibility,
62
+ RoleId,
63
+ RoleName,
64
+ ServiceName,
65
+ UserId,
66
+ )
67
+
68
+ __all__ = [
69
+ # Entities
70
+ "User",
71
+ "Organization",
72
+ "Permission",
73
+ "Role",
74
+ "Membership",
75
+ "AuthContext",
76
+ "Service",
77
+ "OrganizationService",
78
+ # Value objects
79
+ "UserId",
80
+ "OrgId",
81
+ "RoleId",
82
+ "PermissionId",
83
+ "RoleName",
84
+ "PermissionKey",
85
+ "PermissionVisibility",
86
+ "ServiceName",
87
+ "LocalizedText",
88
+ # Ports (Protocols)
89
+ "UserRepository",
90
+ "OrganizationRepository",
91
+ "RoleRepository",
92
+ "MembershipRepository",
93
+ "PermissionCatalogRepository",
94
+ "ServiceRepository",
95
+ "OrganizationServiceRepository",
96
+ # Application DTOs
97
+ "CatalogEntry",
98
+ "ServiceSpec",
99
+ "PermissionScope",
100
+ # Config
101
+ "default_locale",
102
+ # Platform helpers
103
+ "is_platform_context",
104
+ # Exceptions
105
+ "AuthorizationError",
106
+ "NotAMember",
107
+ "MissingPermission",
108
+ "UnknownOrganization",
109
+ "UnknownUser",
110
+ "UnknownRole",
111
+ "UnknownPermission",
112
+ "UnknownService",
113
+ "ServiceNotSaaSAvailable",
114
+ "ServiceNotEnabled",
115
+ "PermissionVisibilityConflict",
116
+ "UserNotProvisioned",
117
+ ]
@@ -0,0 +1 @@
1
+ """Authorization adapter implementations (sqlalchemy, django_orm, cache)."""
@@ -0,0 +1,32 @@
1
+ """Pluggable cache layer for the ACL hot path.
2
+
3
+ Public API:
4
+
5
+ Cache — Protocol port (bytes-in/bytes-out)
6
+ InMemoryTTLCache — zero-deps in-process LRU + TTL cache
7
+ RedisCache — async redis backend (cache-redis extra)
8
+ CachedMembershipRepository — decorator wrapping any MembershipRepository
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .decorators import (
13
+ CachedMembershipRepository,
14
+ CachedOrganizationServiceRepository,
15
+ )
16
+ from .memory import InMemoryTTLCache
17
+ from .protocol import Cache
18
+
19
+ __all__ = [
20
+ "Cache",
21
+ "InMemoryTTLCache",
22
+ "CachedMembershipRepository",
23
+ "CachedOrganizationServiceRepository",
24
+ ]
25
+
26
+ # RedisCache is opt-in via the cache-redis extra. Don't import it
27
+ # eagerly so users without redis installed don't see import errors.
28
+ try:
29
+ from .redis import RedisCache # noqa: F401
30
+ __all__.append("RedisCache")
31
+ except ImportError: # pragma: no cover
32
+ pass
@@ -0,0 +1,181 @@
1
+ """CachedMembershipRepository — decorator wrapping any MembershipRepository.
2
+
3
+ Implements the same Protocol as the underlying repository, so it can be
4
+ passed into use cases anywhere ``MembershipRepository`` is expected. The
5
+ hot-path :meth:`load_auth_context` is the one that actually consults the
6
+ cache; other methods proxy through and invalidate the affected key on
7
+ writes.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from uuid import UUID
14
+
15
+ from ...domain.entities import AuthContext, Membership, OrganizationService
16
+ from ...domain.ports import (
17
+ MembershipRepository,
18
+ OrganizationServiceRepository,
19
+ )
20
+ from ...domain.value_objects import OrgId, RoleId, RoleName, ServiceName, UserId
21
+ from .protocol import Cache
22
+
23
+ DEFAULT_TTL_SECONDS = 30
24
+
25
+
26
+ def _auth_context_key(user_id: UserId, org_id: OrgId) -> str:
27
+ return f"auth_ctx:{user_id}:{org_id}"
28
+
29
+
30
+ def _org_services_key(org_id: OrgId) -> str:
31
+ return f"org_services:{org_id}"
32
+
33
+
34
+ def _serialize_auth_context(ctx: AuthContext) -> bytes:
35
+ payload = {
36
+ "user_id": str(ctx.user_id.value),
37
+ "organization_id": str(ctx.organization_id.value),
38
+ "role_names": sorted(ctx.role_names),
39
+ "perms": sorted(ctx.perms),
40
+ }
41
+ return json.dumps(payload).encode("utf-8")
42
+
43
+
44
+ def _deserialize_auth_context(blob: bytes) -> AuthContext:
45
+ payload = json.loads(blob.decode("utf-8"))
46
+ return AuthContext(
47
+ user_id=UserId(UUID(payload["user_id"])),
48
+ organization_id=OrgId(UUID(payload["organization_id"])),
49
+ role_names=frozenset(payload["role_names"]),
50
+ perms=frozenset(payload["perms"]),
51
+ )
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class CachedMembershipRepository:
56
+ """Cache-decorating wrapper around a real ``MembershipRepository``.
57
+
58
+ Usage::
59
+
60
+ inner = SqlAlchemyMembershipRepository(session_factory=...)
61
+ cache = InMemoryTTLCache(max_entries=10_000)
62
+ membership_repo = CachedMembershipRepository(
63
+ inner=inner, cache=cache, ttl_seconds=30,
64
+ )
65
+
66
+ Cache invalidation:
67
+ - :meth:`upsert` and :meth:`delete` invalidate the affected
68
+ ``(user_id, org_id)`` key.
69
+ - **Role-level changes** (e.g. updating a role's permission set)
70
+ affect many cached entries and are NOT auto-invalidated. The
71
+ calling use case must call ``cache.invalidate_prefix("auth_ctx:")``
72
+ after the role mutation. The package documents this convention
73
+ in ``docs/Caching.md``; we deliberately don't hide it because
74
+ guessing wrong here would silently serve stale perms.
75
+ """
76
+
77
+ inner: MembershipRepository
78
+ cache: Cache
79
+ ttl_seconds: int = DEFAULT_TTL_SECONDS
80
+
81
+ async def get(
82
+ self, user_id: UserId, org_id: OrgId
83
+ ) -> Membership | None:
84
+ return await self.inner.get(user_id, org_id)
85
+
86
+ async def upsert(
87
+ self,
88
+ *,
89
+ user_id: UserId,
90
+ org_id: OrgId,
91
+ role_id: RoleId,
92
+ status: str,
93
+ ) -> Membership:
94
+ result = await self.inner.upsert(
95
+ user_id=user_id,
96
+ org_id=org_id,
97
+ role_id=role_id,
98
+ status=status,
99
+ )
100
+ await self.cache.delete(_auth_context_key(user_id, org_id))
101
+ return result
102
+
103
+ async def delete(self, user_id: UserId, org_id: OrgId) -> None:
104
+ await self.inner.delete(user_id, org_id)
105
+ await self.cache.delete(_auth_context_key(user_id, org_id))
106
+
107
+ async def load_auth_context(
108
+ self, user_id: UserId, org_id: OrgId
109
+ ) -> AuthContext | None:
110
+ cache_key = _auth_context_key(user_id, org_id)
111
+ blob = await self.cache.get(cache_key)
112
+ if blob is not None:
113
+ return _deserialize_auth_context(blob)
114
+ ctx = await self.inner.load_auth_context(user_id, org_id)
115
+ if ctx is not None:
116
+ await self.cache.set(
117
+ cache_key,
118
+ _serialize_auth_context(ctx),
119
+ ttl_seconds=self.ttl_seconds,
120
+ )
121
+ return ctx
122
+
123
+ async def list_for_user(self, user_id: UserId) -> list[Membership]:
124
+ return await self.inner.list_for_user(user_id)
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class CachedOrganizationServiceRepository:
129
+ """Cache-decorating wrapper around an ``OrganizationServiceRepository``.
130
+
131
+ Caches the per-org enabled-service-name set — read on every protected
132
+ request by the service guard in ``ResolveAuthContextUseCase``. Writes
133
+ (``enable`` / ``disable`` / ``bulk_enable``) invalidate the org's key.
134
+ """
135
+
136
+ inner: OrganizationServiceRepository
137
+ cache: Cache
138
+ ttl_seconds: int = DEFAULT_TTL_SECONDS
139
+
140
+ async def list_enabled_service_names(self, org_id: OrgId) -> set[str]:
141
+ cache_key = _org_services_key(org_id)
142
+ blob = await self.cache.get(cache_key)
143
+ if blob is not None:
144
+ return set(json.loads(blob.decode("utf-8")))
145
+ names = await self.inner.list_enabled_service_names(org_id)
146
+ await self.cache.set(
147
+ cache_key,
148
+ json.dumps(sorted(names)).encode("utf-8"),
149
+ ttl_seconds=self.ttl_seconds,
150
+ )
151
+ return names
152
+
153
+ async def get(
154
+ self, org_id: OrgId, service_name: ServiceName
155
+ ) -> OrganizationService | None:
156
+ return await self.inner.get(org_id, service_name)
157
+
158
+ async def enable(
159
+ self, org_id: OrgId, service_name: ServiceName, *, source: str
160
+ ) -> OrganizationService:
161
+ result = await self.inner.enable(
162
+ org_id, service_name, source=source
163
+ )
164
+ await self.cache.delete(_org_services_key(org_id))
165
+ return result
166
+
167
+ async def disable(
168
+ self, org_id: OrgId, service_name: ServiceName
169
+ ) -> None:
170
+ await self.inner.disable(org_id, service_name)
171
+ await self.cache.delete(_org_services_key(org_id))
172
+
173
+ async def bulk_enable(
174
+ self,
175
+ org_id: OrgId,
176
+ service_names: "list[ServiceName] | tuple[ServiceName, ...]",
177
+ *,
178
+ source: str,
179
+ ) -> None:
180
+ await self.inner.bulk_enable(org_id, service_names, source=source)
181
+ await self.cache.delete(_org_services_key(org_id))
@@ -0,0 +1,61 @@
1
+ """In-process LRU + TTL cache (zero new dependencies)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class InMemoryTTLCache:
12
+ """Per-process LRU cache with per-entry TTL.
13
+
14
+ Suitable for single-replica services or when freshness can tolerate
15
+ per-pod divergence. For horizontally-scaled services that need
16
+ cache coherence, use :class:`RedisCache` instead.
17
+
18
+ The cache is async-safe via an internal :class:`asyncio.Lock` —
19
+ multiple coroutines can hit it concurrently without races.
20
+ """
21
+
22
+ max_entries: int = 10_000
23
+ _store: OrderedDict[str, tuple[bytes, float]] = field(
24
+ default_factory=OrderedDict
25
+ )
26
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock)
27
+
28
+ async def get(self, key: str) -> bytes | None:
29
+ async with self._lock:
30
+ entry = self._store.get(key)
31
+ if entry is None:
32
+ return None
33
+ value, expires_at = entry
34
+ if expires_at < time.monotonic():
35
+ # Expired — drop and miss
36
+ self._store.pop(key, None)
37
+ return None
38
+ # LRU touch
39
+ self._store.move_to_end(key)
40
+ return value
41
+
42
+ async def set(
43
+ self, key: str, value: bytes, *, ttl_seconds: int
44
+ ) -> None:
45
+ async with self._lock:
46
+ expires_at = time.monotonic() + ttl_seconds
47
+ if key in self._store:
48
+ self._store.move_to_end(key)
49
+ self._store[key] = (value, expires_at)
50
+ while len(self._store) > self.max_entries:
51
+ self._store.popitem(last=False)
52
+
53
+ async def delete(self, key: str) -> None:
54
+ async with self._lock:
55
+ self._store.pop(key, None)
56
+
57
+ async def invalidate_prefix(self, prefix: str) -> None:
58
+ async with self._lock:
59
+ doomed = [k for k in self._store if k.startswith(prefix)]
60
+ for k in doomed:
61
+ self._store.pop(k, None)
@@ -0,0 +1,36 @@
1
+ """Cache port — abstract bytes-in/bytes-out cache."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+
7
+ class Cache(Protocol):
8
+ """Bytes-keyed cache abstraction.
9
+
10
+ Implementations are bytes-in / bytes-out: serialization is the
11
+ decorator's responsibility, not the cache's. This keeps the protocol
12
+ portable across in-memory, Redis, memcached, etc.
13
+ """
14
+
15
+ async def get(self, key: str) -> bytes | None:
16
+ """Return the cached value, or ``None`` if missing or expired."""
17
+ ...
18
+
19
+ async def set(
20
+ self, key: str, value: bytes, *, ttl_seconds: int
21
+ ) -> None:
22
+ """Store ``value`` at ``key`` with the given TTL in seconds."""
23
+ ...
24
+
25
+ async def delete(self, key: str) -> None:
26
+ """Remove ``key`` if present. No-op if missing."""
27
+ ...
28
+
29
+ async def invalidate_prefix(self, prefix: str) -> None:
30
+ """Remove every key starting with ``prefix``.
31
+
32
+ Used for bulk invalidation when role-level changes affect many
33
+ cached AuthContexts at once. Implementations may use SCAN+DEL
34
+ on Redis or a single dict comprehension in memory.
35
+ """
36
+ ...
@@ -0,0 +1,60 @@
1
+ """Async Redis cache backend (cache-redis extra)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ try:
8
+ import redis.asyncio as redis_asyncio # noqa: F401
9
+ except ImportError as exc: # pragma: no cover
10
+ raise ImportError(
11
+ "pkg_auth.authorization.adapters.cache.redis requires the redis "
12
+ "package. Install with: pip install pkg-auth[cache-redis]"
13
+ ) from exc
14
+
15
+ if TYPE_CHECKING:
16
+ from redis.asyncio import Redis
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class RedisCache:
21
+ """Async Redis-backed :class:`Cache` implementation.
22
+
23
+ The Redis client is injected — services build their own
24
+ ``redis.asyncio.Redis`` (with auth, sentinel, etc.) and hand it to
25
+ the cache. The cache itself only knows how to ``GET`` / ``SET`` /
26
+ ``DEL`` / ``SCAN``.
27
+
28
+ All keys are namespaced by ``namespace`` so this cache can coexist
29
+ with other Redis users in the same database without collision.
30
+ """
31
+
32
+ client: "Redis"
33
+ namespace: str = "pkg_auth:acl"
34
+ scan_count: int = 500
35
+
36
+ def _k(self, key: str) -> str:
37
+ return f"{self.namespace}:{key}"
38
+
39
+ async def get(self, key: str) -> bytes | None:
40
+ return await self.client.get(self._k(key))
41
+
42
+ async def set(
43
+ self, key: str, value: bytes, *, ttl_seconds: int
44
+ ) -> None:
45
+ await self.client.set(self._k(key), value, ex=ttl_seconds)
46
+
47
+ async def delete(self, key: str) -> None:
48
+ await self.client.delete(self._k(key))
49
+
50
+ async def invalidate_prefix(self, prefix: str) -> None:
51
+ match = f"{self._k(prefix)}*"
52
+ cursor = 0
53
+ while True:
54
+ cursor, keys = await self.client.scan(
55
+ cursor=cursor, match=match, count=self.scan_count
56
+ )
57
+ if keys:
58
+ await self.client.delete(*keys)
59
+ if cursor == 0:
60
+ break
@@ -0,0 +1,37 @@
1
+ """Django ORM adapter for the ACL schema (managed=False mirror models).
2
+
3
+ The schema is owned by the SQLAlchemy adapter's Alembic migrations.
4
+ This module provides Django ORM models pointing at the *same* physical
5
+ tables, with ``Meta.managed = False`` so Django's ``makemigrations`` will
6
+ not try to manage them. Repositories implement the same Protocols as
7
+ the SQLAlchemy adapter, using Django's async ORM API (``acreate``,
8
+ ``aget``, etc.).
9
+
10
+ Importing this module requires Django to be installed:
11
+
12
+ pip install pkg-auth[acl-django]
13
+
14
+ The Django app label is ``pkg_auth_acl``. Add
15
+ ``"pkg_auth.authorization.adapters.django_orm"`` to your service's
16
+ ``INSTALLED_APPS``.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ try:
21
+ import django # noqa: F401
22
+ except ImportError as exc: # pragma: no cover
23
+ raise ImportError(
24
+ "pkg_auth.authorization.adapters.django_orm requires Django. "
25
+ "Install with: pip install pkg-auth[acl-django]"
26
+ ) from exc
27
+
28
+ default_app_config = "pkg_auth.authorization.adapters.django_orm.apps.PkgAuthAclConfig"
29
+
30
+ # NOTE: do NOT import .mixins or .models from this __init__. Django needs
31
+ # the apps registry to be ready before any ``models.Model`` subclass can
32
+ # be defined, and __init__.py runs during app loading. Consumers should
33
+ # import the abstract mixins directly:
34
+ #
35
+ # from pkg_auth.authorization.adapters.django_orm.mixins import UserMixin
36
+
37
+ __all__ = ["default_app_config"]
@@ -0,0 +1,24 @@
1
+ """Django AppConfig for the pkg_auth ACL ORM mirror models."""
2
+ from __future__ import annotations
3
+
4
+ from django.apps import AppConfig
5
+
6
+
7
+ class PkgAuthAclConfig(AppConfig):
8
+ """Django app holding the ACL ORM mirror models.
9
+
10
+ Tables are owned by Alembic migrations from the SQLAlchemy adapter,
11
+ so all models in this app declare ``Meta.managed = False``. Adding
12
+ this app to ``INSTALLED_APPS`` lets Django code query the ACL
13
+ tables via the ORM without managing the schema.
14
+ """
15
+
16
+ name = "pkg_auth.authorization.adapters.django_orm"
17
+ label = "pkg_auth_acl"
18
+ verbose_name = "pkg_auth ACL"
19
+ # default_auto_field must be an AutoField subclass — Django uses it
20
+ # only for models that don't declare their own PK. Every concrete
21
+ # ACL model in models.py declares a UUIDField PK explicitly, so this
22
+ # value is effectively unused at runtime; we leave it as the Django
23
+ # default for compatibility.
24
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,142 @@
1
+ """Abstract Django model mixins for the ACL schema.
2
+
3
+ These are the Django analog of the SQLAlchemy mixins in
4
+ ``pkg_auth.authorization.adapters.sqlalchemy.mixins`` — they declare the
5
+ ACL-essential columns without specifying ``id``, ``db_table``, FK columns,
6
+ or relationships. Consuming services that want to extend an ACL table
7
+ with their own columns subclass the mixin and provide their own concrete
8
+ ``Meta`` (``abstract = False``).
9
+
10
+ Example (service extends UserMixin)::
11
+
12
+ from pkg_auth.authorization.adapters.django_orm.mixins import UserMixin
13
+
14
+ class User(UserMixin):
15
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4)
16
+ username = models.CharField(max_length=255)
17
+
18
+ class Meta:
19
+ db_table = "users"
20
+ app_label = "accounts"
21
+
22
+ Services that do NOT need to extend use the default concrete models in
23
+ ``models.py`` (managed=False mirrors) directly.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from django.db import models
28
+
29
+
30
+ class UserMixin(models.Model):
31
+ """ACL columns for the users table."""
32
+
33
+ keycloak_sub = models.CharField(max_length=64, unique=True)
34
+ email = models.CharField(max_length=255)
35
+ full_name = models.CharField(max_length=255, null=True, blank=True)
36
+ first_seen_at = models.DateTimeField(auto_now_add=True)
37
+ last_seen_at = models.DateTimeField(auto_now=True)
38
+ created_at = models.DateTimeField(auto_now_add=True)
39
+ updated_at = models.DateTimeField(auto_now=True)
40
+
41
+ class Meta:
42
+ abstract = True
43
+ app_label = "pkg_auth_acl"
44
+
45
+
46
+ class OrganizationMixin(models.Model):
47
+ """ACL columns for the organizations table."""
48
+
49
+ slug = models.CharField(max_length=255, unique=True)
50
+ name = models.CharField(max_length=255)
51
+ created_at = models.DateTimeField(auto_now_add=True)
52
+ updated_at = models.DateTimeField(auto_now=True)
53
+
54
+ class Meta:
55
+ abstract = True
56
+ app_label = "pkg_auth_acl"
57
+
58
+
59
+ class PermissionMixin(models.Model):
60
+ """ACL columns for the permissions (catalog) table.
61
+
62
+ ``visibility`` controls which role builders may see/use the permission:
63
+ ``platform_only`` (platform org only), ``shared`` (everywhere, default),
64
+ or ``tenant_only`` (normal orgs only — hidden from the platform org).
65
+ ``description`` is a localized JSONB ``{locale: text}`` map.
66
+ """
67
+
68
+ key = models.CharField(max_length=255, unique=True)
69
+ service_name = models.CharField(max_length=64)
70
+ description = models.JSONField(null=True, blank=True)
71
+ visibility = models.CharField(max_length=32, default="shared")
72
+ registered_at = models.DateTimeField(auto_now=True)
73
+
74
+ class Meta:
75
+ abstract = True
76
+ app_label = "pkg_auth_acl"
77
+
78
+
79
+ class ServiceMixin(models.Model):
80
+ """ACL columns for the ``services`` table (the service registry).
81
+
82
+ ``auto_provision`` and ``saas_available`` are vendor-controlled and set
83
+ only via the ``pkg-auth-sync-services`` path. ``display_label`` is a
84
+ localized JSONB map.
85
+ """
86
+
87
+ name = models.CharField(max_length=64, unique=True)
88
+ display_label = models.JSONField(null=True, blank=True)
89
+ auto_provision = models.BooleanField(default=False)
90
+ saas_available = models.BooleanField(default=False)
91
+ created_at = models.DateTimeField(auto_now_add=True)
92
+ updated_at = models.DateTimeField(auto_now=True)
93
+
94
+ class Meta:
95
+ abstract = True
96
+ app_label = "pkg_auth_acl"
97
+
98
+
99
+ class OrganizationServiceMixin(models.Model):
100
+ """ACL columns for the ``organization_services`` table (per-org service
101
+ entitlements). The FK to organizations lives on the concrete model.
102
+ """
103
+
104
+ service_name = models.CharField(max_length=64)
105
+ enabled = models.BooleanField(default=True)
106
+ source = models.CharField(max_length=16, default="manual")
107
+ granted_at = models.DateTimeField(auto_now_add=True)
108
+
109
+ class Meta:
110
+ abstract = True
111
+ app_label = "pkg_auth_acl"
112
+
113
+
114
+ class RoleMixin(models.Model):
115
+ """ACL columns for the roles table."""
116
+
117
+ name = models.CharField(max_length=128)
118
+ description = models.TextField(null=True, blank=True)
119
+ created_at = models.DateTimeField(auto_now_add=True)
120
+ updated_at = models.DateTimeField(auto_now=True)
121
+
122
+ class Meta:
123
+ abstract = True
124
+ app_label = "pkg_auth_acl"
125
+
126
+
127
+ class MembershipMixin(models.Model):
128
+ """ACL columns for the memberships table.
129
+
130
+ Does NOT include ``status`` — services define their own status type
131
+ (string, choices field, etc.). Does NOT include FK columns or
132
+ relationships — those depend on the concrete model's schema and
133
+ table names.
134
+ """
135
+
136
+ joined_at = models.DateTimeField(auto_now_add=True)
137
+ created_at = models.DateTimeField(auto_now_add=True)
138
+ updated_at = models.DateTimeField(auto_now=True)
139
+
140
+ class Meta:
141
+ abstract = True
142
+ app_label = "pkg_auth_acl"