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.
- pkg_auth/__init__.py +15 -0
- pkg_auth/admin/__init__.py +35 -0
- pkg_auth/admin/cli.py +87 -0
- pkg_auth/admin/client.py +401 -0
- pkg_auth/admin/env.py +74 -0
- pkg_auth/admin/helpers.py +113 -0
- pkg_auth/admin/provision_client.py +86 -0
- pkg_auth/admin/settings.py +33 -0
- pkg_auth/authentication/__init__.py +33 -0
- pkg_auth/authentication/adapters/__init__.py +1 -0
- pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
- pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
- pkg_auth/authentication/application/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
- pkg_auth/authentication/domain/__init__.py +1 -0
- pkg_auth/authentication/domain/entities.py +50 -0
- pkg_auth/authentication/domain/exceptions.py +18 -0
- pkg_auth/authentication/domain/ports.py +26 -0
- pkg_auth/authentication/domain/value_objects.py +42 -0
- pkg_auth/authorization/__init__.py +117 -0
- pkg_auth/authorization/adapters/__init__.py +1 -0
- pkg_auth/authorization/adapters/cache/__init__.py +32 -0
- pkg_auth/authorization/adapters/cache/decorators.py +181 -0
- pkg_auth/authorization/adapters/cache/memory.py +61 -0
- pkg_auth/authorization/adapters/cache/protocol.py +36 -0
- pkg_auth/authorization/adapters/cache/redis.py +60 -0
- pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
- pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
- pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
- pkg_auth/authorization/adapters/django_orm/models.py +226 -0
- pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
- pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
- pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
- pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
- pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
- pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
- pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
- pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
- pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
- pkg_auth/authorization/application/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
- pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
- pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
- pkg_auth/authorization/application/use_cases/create_role.py +69 -0
- pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
- pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
- pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
- pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
- pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
- pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
- pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
- pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
- pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
- pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
- pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
- pkg_auth/authorization/application/use_cases/update_role.py +61 -0
- pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
- pkg_auth/authorization/cli/__init__.py +1 -0
- pkg_auth/authorization/cli/sync_catalog.py +180 -0
- pkg_auth/authorization/cli/sync_services.py +151 -0
- pkg_auth/authorization/config.py +21 -0
- pkg_auth/authorization/domain/__init__.py +1 -0
- pkg_auth/authorization/domain/entities.py +192 -0
- pkg_auth/authorization/domain/exceptions.py +68 -0
- pkg_auth/authorization/domain/ports.py +217 -0
- pkg_auth/authorization/domain/value_objects.py +208 -0
- pkg_auth/authorization/platform.py +47 -0
- pkg_auth/integrations/__init__.py +0 -0
- pkg_auth/integrations/django/__init__.py +32 -0
- pkg_auth/integrations/django/apps.py +10 -0
- pkg_auth/integrations/django/auth_context_middleware.py +105 -0
- pkg_auth/integrations/django/decorators.py +74 -0
- pkg_auth/integrations/django/install.py +136 -0
- pkg_auth/integrations/django/middleware.py +63 -0
- pkg_auth/integrations/fastapi/__init__.py +26 -0
- pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
- pkg_auth/integrations/fastapi/auth_factory.py +84 -0
- pkg_auth/integrations/fastapi/decorators.py +55 -0
- pkg_auth/integrations/fastapi/errors.py +72 -0
- pkg_auth/integrations/fastapi/identity_dep.py +41 -0
- pkg_auth/integrations/strawberry/__init__.py +20 -0
- pkg_auth/integrations/strawberry/auth.py +137 -0
- pkg_auth/integrations/strawberry/permissions.py +56 -0
- pkg_auth-3.0.0.dist-info/METADATA +147 -0
- pkg_auth-3.0.0.dist-info/RECORD +110 -0
- pkg_auth-3.0.0.dist-info/WHEEL +5 -0
- pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
- 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"
|