resourcey 0.0.1__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.
- resourcey/__init__.py +4 -0
- resourcey/__main__.py +48 -0
- resourcey/auth/auth_api_key.py +237 -0
- resourcey/auth/auth_api_key_resource.py +251 -0
- resourcey/auth/auth_api_key_service.py +94 -0
- resourcey/auth/auth_authorized_dependency.py +149 -0
- resourcey/auth/auth_authorized_service.py +274 -0
- resourcey/auth/auth_config.py +75 -0
- resourcey/auth/auth_cookie.py +139 -0
- resourcey/auth/auth_me_routes.py +147 -0
- resourcey/auth/auth_oauth.py +362 -0
- resourcey/auth/auth_oauth_client.py +270 -0
- resourcey/auth/auth_oauth_config.py +79 -0
- resourcey/auth/auth_oauth_provider.py +257 -0
- resourcey/auth/auth_oauth_routes.py +474 -0
- resourcey/auth/auth_oauth_service.py +85 -0
- resourcey/auth/auth_oauth_setup.py +206 -0
- resourcey/auth/auth_oauth_token.py +408 -0
- resourcey/auth/auth_policy.py +275 -0
- resourcey/auth/auth_principal.py +340 -0
- resourcey/auth/auth_rbac.py +213 -0
- resourcey/auth/auth_rbac_resolver.py +128 -0
- resourcey/auth/auth_rbac_store.py +180 -0
- resourcey/auth/auth_role.py +124 -0
- resourcey/cache/cache_defaults.py +96 -0
- resourcey/cache/cache_header.py +83 -0
- resourcey/cache/cache_store.py +105 -0
- resourcey/cache/cache_strategy.py +257 -0
- resourcey/cache/cached_resource.py +177 -0
- resourcey/cache/cached_service.py +247 -0
- resourcey/config/config_base.py +185 -0
- resourcey/config/lazy_field.py +188 -0
- resourcey/core/dto.py +748 -0
- resourcey/core/errors.py +46 -0
- resourcey/core/manifest.py +129 -0
- resourcey/core/resource.py +224 -0
- resourcey/core/service.py +477 -0
- resourcey/encryption/encryption_config.py +70 -0
- resourcey/encryption/encryption_service.py +200 -0
- resourcey/filestore/file_config.py +46 -0
- resourcey/filestore/file_resource.py +476 -0
- resourcey/filestore/file_routes.py +325 -0
- resourcey/filestore/file_store.py +124 -0
- resourcey/filestore/local_file_store.py +200 -0
- resourcey/filestore/s3_file_store.py +263 -0
- resourcey/filestore/signed_url.py +142 -0
- resourcey/filestore/sql_file_store.py +254 -0
- resourcey/http/app.py +135 -0
- resourcey/http/batch_routes.py +466 -0
- resourcey/http/dependency_builder.py +123 -0
- resourcey/http/http_config.py +68 -0
- resourcey/http/routes.py +957 -0
- resourcey/jobs/jobs_config.py +62 -0
- resourcey/jobs/jobs_details.py +71 -0
- resourcey/jobs/jobs_model.py +227 -0
- resourcey/jobs/jobs_runner.py +470 -0
- resourcey/list/list_resource.py +291 -0
- resourcey/list/list_service.py +270 -0
- resourcey/list/pydantic_2_dto.py +84 -0
- resourcey/mongo/embedded.py +127 -0
- resourcey/mongo/mongo_client.py +170 -0
- resourcey/mongo/mongo_config.py +118 -0
- resourcey/mongo/mongo_filter_converter.py +363 -0
- resourcey/mongo/mongo_resource.py +373 -0
- resourcey/mongo/mongo_service.py +481 -0
- resourcey/mongo/mongo_sort_converter.py +93 -0
- resourcey/py.typed +0 -0
- resourcey/realtime/realtime_asyncapi.py +349 -0
- resourcey/realtime/realtime_channel.py +120 -0
- resourcey/realtime/realtime_config.py +29 -0
- resourcey/realtime/realtime_event.py +52 -0
- resourcey/realtime/realtime_redis_channel.py +116 -0
- resourcey/realtime/realtime_routes.py +386 -0
- resourcey/sql/cursor.py +71 -0
- resourcey/sql/db_config.py +60 -0
- resourcey/sql/filter_converter.py +438 -0
- resourcey/sql/model_type.py +64 -0
- resourcey/sql/session_manager.py +131 -0
- resourcey/sql/sort_converter.py +101 -0
- resourcey/sql/sql_config.py +49 -0
- resourcey/sql/sql_resource.py +345 -0
- resourcey/sql/sql_service.py +453 -0
- resourcey/sql/sqlalchemy_2_dto.py +315 -0
- resourcey/tasks/cli.py +98 -0
- resourcey/tasks/cron.py +231 -0
- resourcey/tasks/scheduler.py +200 -0
- resourcey/tasks/task.py +148 -0
- resourcey/triggers/redis_trigger.py +128 -0
- resourcey/triggers/trigger.py +78 -0
- resourcey/triggers/trigger_config.py +71 -0
- resourcey/triggers/trigger_runner.py +95 -0
- resourcey/triggers/triggered_dependency_builder.py +178 -0
- resourcey/triggers/triggered_resource.py +185 -0
- resourcey/triggers/triggered_service.py +175 -0
- resourcey/triggers/webhook_trigger.py +203 -0
- resourcey/util/cursor.py +132 -0
- resourcey/util/env_parser.py +525 -0
- resourcey/util/import_paths.py +50 -0
- resourcey/util/missing.py +60 -0
- resourcey/util/models.py +367 -0
- resourcey/util/naming.py +57 -0
- resourcey/util/search_filter.py +510 -0
- resourcey/util/secret_serialization.py +76 -0
- resourcey/util/singleton.py +113 -0
- resourcey/util/sort_order.py +96 -0
- resourcey/view/resource_view.py +361 -0
- resourcey/view/view_service.py +136 -0
- resourcey-0.0.1.dist-info/METADATA +504 -0
- resourcey-0.0.1.dist-info/RECORD +111 -0
- resourcey-0.0.1.dist-info/WHEEL +4 -0
- resourcey-0.0.1.dist-info/licenses/LICENSE +21 -0
resourcey/__init__.py
ADDED
resourcey/__main__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""``python -m resourcey`` — the framework command dispatcher (issue #15).
|
|
2
|
+
|
|
3
|
+
Apps are started with ``uvicorn <app_module>:app`` (the standard FastAPI path) —
|
|
4
|
+
there is no ``resourcey run``, because the CLI cannot know the user's app module.
|
|
5
|
+
What the dispatcher does provide is the background-task entry point::
|
|
6
|
+
|
|
7
|
+
python -m resourcey tasks list app:manifest
|
|
8
|
+
python -m resourcey tasks run app:manifest [name]
|
|
9
|
+
|
|
10
|
+
The framework does no ``.env`` loading of its own, so run it with the app's
|
|
11
|
+
environment populated (e.g. ``uv run --env-file .env python -m resourcey …``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# Each subcommand maps to a callable taking the parsed Namespace and returning an
|
|
21
|
+
# exit code. Populated lazily by ``_build_parser`` so importing the dispatcher
|
|
22
|
+
# does not import every command's dependencies.
|
|
23
|
+
_COMMANDS: dict[str, Callable[[Any], int]] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
27
|
+
"""Dispatch a ``resourcey`` command. Returns a process exit code."""
|
|
28
|
+
parser = _build_parser()
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
if args.command is None:
|
|
31
|
+
parser.print_help()
|
|
32
|
+
return 0
|
|
33
|
+
return int(_COMMANDS[args.command](args) or 0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
37
|
+
parser = argparse.ArgumentParser(prog="resourcey", description="resourcey CLI.")
|
|
38
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
39
|
+
|
|
40
|
+
from resourcey.tasks import cli as tasks_cli
|
|
41
|
+
|
|
42
|
+
tasks_cli.add_subparser(subparsers)
|
|
43
|
+
_COMMANDS["tasks"] = tasks_cli.run
|
|
44
|
+
return parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""API-key authentication (issue #118, re-expressed in #131).
|
|
2
|
+
|
|
3
|
+
:class:`ApiKeyAuthenticator` is an
|
|
4
|
+
:class:`~resourcey.auth.auth_principal.Authenticator`, so it plugs into
|
|
5
|
+
:class:`~resourcey.auth.auth_authorized_dependency.AuthorizedDependencyBuilder`
|
|
6
|
+
alongside every other method. It resolves a presented API key to a
|
|
7
|
+
:class:`~resourcey.auth.auth_principal.Principal`:
|
|
8
|
+
|
|
9
|
+
* a **DB-backed** key resolves to its owner (``principal.id == row.user_id``)
|
|
10
|
+
when the row carries one, so a policy can scope rows to the key's owner; a key
|
|
11
|
+
with no owner resolves to a **service** principal;
|
|
12
|
+
* a **config-list** key resolves to a service principal (optionally named by
|
|
13
|
+
``principal_id``).
|
|
14
|
+
|
|
15
|
+
It holds the **inner** key resource (the DB-backed or config-list resource that
|
|
16
|
+
exposes ``find_by_key``) and validates a presented key by hashing it and
|
|
17
|
+
searching for that digest. It deliberately does not hold the exposed
|
|
18
|
+
``ResourceView``, whose ``ViewService`` forwards only the eight standard actions.
|
|
19
|
+
|
|
20
|
+
Contract:
|
|
21
|
+
|
|
22
|
+
* two accepted headers — ``X-API-Key`` and, failing that,
|
|
23
|
+
``Authorization: Bearer <key>``, both declared with ``Security`` so they appear
|
|
24
|
+
in the OpenAPI schema;
|
|
25
|
+
* **fail-closed** — no key resource, or a key resource that matches nothing,
|
|
26
|
+
denies every request;
|
|
27
|
+
* a **revoked** (``active=False``) or **expired** row denies the request;
|
|
28
|
+
* the key check runs over a **fresh ctx**, so it always opens and closes its own
|
|
29
|
+
storage and never adopts the target resource's session.
|
|
30
|
+
|
|
31
|
+
This module is part of ``resourcey.auth``; it imports only lower framework layers.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from typing import Annotated, Any
|
|
39
|
+
from uuid import UUID
|
|
40
|
+
|
|
41
|
+
from fastapi import HTTPException, Request, Security, status
|
|
42
|
+
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
|
|
43
|
+
|
|
44
|
+
from resourcey.auth.auth_api_key_resource import hash_api_key
|
|
45
|
+
from resourcey.auth.auth_principal import (
|
|
46
|
+
Authenticator,
|
|
47
|
+
AuthResult,
|
|
48
|
+
Principal,
|
|
49
|
+
PrincipalKind,
|
|
50
|
+
principal_is_active,
|
|
51
|
+
)
|
|
52
|
+
from resourcey.auth.auth_role import roles_from_credential
|
|
53
|
+
|
|
54
|
+
API_KEY_HEADER_NAME = "X-API-Key"
|
|
55
|
+
|
|
56
|
+
# ``Bearer`` is the canonical scheme for the ``Authorization`` fallback; the
|
|
57
|
+
# dedicated ``X-API-Key`` header has no registered scheme, so the realm names it.
|
|
58
|
+
API_KEY_CHALLENGE = 'Bearer realm="api-key"'
|
|
59
|
+
|
|
60
|
+
# ``auto_error=False`` so the authenticator decides the response; declaring them
|
|
61
|
+
# via ``Security`` keeps the schemes visible in the OpenAPI schema.
|
|
62
|
+
_api_key_header = APIKeyHeader(
|
|
63
|
+
name=API_KEY_HEADER_NAME,
|
|
64
|
+
scheme_name="ApiKeyHeader",
|
|
65
|
+
auto_error=False,
|
|
66
|
+
description="API key sent as the `X-API-Key` header.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_bearer_scheme = HTTPBearer(
|
|
70
|
+
scheme_name="ApiKeyBearer",
|
|
71
|
+
auto_error=False,
|
|
72
|
+
description="API key sent as `Authorization: Bearer <key>`.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ApiKeyAuthenticator(Authenticator):
|
|
77
|
+
"""Authenticate a request against a stored or configured set of API keys.
|
|
78
|
+
|
|
79
|
+
``key_resource`` is the **inner** key resource whose service exposes
|
|
80
|
+
``find_by_key``; ``None`` fails closed. ``principal_id`` is the fixed id for
|
|
81
|
+
**config-list** keys (a DB row's owner always wins). ``user_resource`` is an
|
|
82
|
+
optional principal store the resolved principal is validated against.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
key_resource: Any = None
|
|
86
|
+
principal_id: UUID | None = None
|
|
87
|
+
user_resource: Any = None
|
|
88
|
+
|
|
89
|
+
async def authenticate(self, request: Any) -> AuthResult:
|
|
90
|
+
"""Resolve the request's API key (from either header) to an :class:`AuthResult`."""
|
|
91
|
+
presented = request.headers.get(API_KEY_HEADER_NAME)
|
|
92
|
+
if not presented:
|
|
93
|
+
authorization = request.headers.get("Authorization")
|
|
94
|
+
if authorization and authorization.lower().startswith("bearer "):
|
|
95
|
+
presented = authorization[len("bearer ") :].strip()
|
|
96
|
+
if not presented:
|
|
97
|
+
return AuthResult.absent()
|
|
98
|
+
row = await self.lookup_api_key(presented)
|
|
99
|
+
if row is None:
|
|
100
|
+
return AuthResult.invalid()
|
|
101
|
+
principal = self._principal_for(row)
|
|
102
|
+
if not await self._principal_is_active(principal):
|
|
103
|
+
return AuthResult.invalid()
|
|
104
|
+
return AuthResult.authenticated(principal)
|
|
105
|
+
|
|
106
|
+
def dependency(self) -> Callable[..., Any]:
|
|
107
|
+
"""A FastAPI dependency declaring the two key schemes and returning the result.
|
|
108
|
+
|
|
109
|
+
Declares ``X-API-Key`` / ``Bearer`` via ``Security`` so both appear in the
|
|
110
|
+
OpenAPI schema, then defers to :meth:`authenticate` for the decision.
|
|
111
|
+
"""
|
|
112
|
+
from resourcey.auth.auth_principal import resolve_auth_result
|
|
113
|
+
|
|
114
|
+
authenticator = self
|
|
115
|
+
|
|
116
|
+
async def dependency(
|
|
117
|
+
request: Request,
|
|
118
|
+
x_api_key: Annotated[str | None, Security(_api_key_header)] = None,
|
|
119
|
+
bearer: Annotated[HTTPAuthorizationCredentials | None, Security(_bearer_scheme)] = None,
|
|
120
|
+
) -> AuthResult:
|
|
121
|
+
return await resolve_auth_result(request, authenticator)
|
|
122
|
+
|
|
123
|
+
dependency.__name__ = "api_key_authenticator_dependency"
|
|
124
|
+
return dependency
|
|
125
|
+
|
|
126
|
+
def challenge(self) -> str:
|
|
127
|
+
"""The API-key realm, so a 401 names the scheme (``Bearer realm="api-key"``)."""
|
|
128
|
+
return API_KEY_CHALLENGE
|
|
129
|
+
|
|
130
|
+
async def api_key_dependency(
|
|
131
|
+
self,
|
|
132
|
+
x_api_key: Annotated[str | None, Security(_api_key_header)] = None,
|
|
133
|
+
bearer: Annotated[HTTPAuthorizationCredentials | None, Security(_bearer_scheme)] = None,
|
|
134
|
+
) -> Principal:
|
|
135
|
+
"""A standalone FastAPI dependency: 401 unless a valid key is presented.
|
|
136
|
+
|
|
137
|
+
For a custom router or endpoint that is not secured by the builder.
|
|
138
|
+
"""
|
|
139
|
+
presented = x_api_key
|
|
140
|
+
if presented is None and bearer is not None:
|
|
141
|
+
presented = bearer.credentials
|
|
142
|
+
row = await self.lookup_api_key(presented) if presented else None
|
|
143
|
+
if row is None:
|
|
144
|
+
raise _reject()
|
|
145
|
+
principal = self._principal_for(row)
|
|
146
|
+
# The same store check as ``authenticate``, so a custom router cannot
|
|
147
|
+
# accept a key whose principal is missing or disabled.
|
|
148
|
+
if not await self._principal_is_active(principal):
|
|
149
|
+
raise _reject()
|
|
150
|
+
return principal
|
|
151
|
+
|
|
152
|
+
async def lookup_api_key(self, presented: str) -> Any | None:
|
|
153
|
+
"""The stored key entry for ``presented``, or ``None`` (fail-closed).
|
|
154
|
+
|
|
155
|
+
The value is hashed (SHA-256) and the digest searched for; the plaintext is
|
|
156
|
+
never compared. An **inactive** or **expired** row also yields ``None``.
|
|
157
|
+
The lookup runs over a **fresh, call-scoped ctx**, so the key service never
|
|
158
|
+
adopts the target resource's session.
|
|
159
|
+
"""
|
|
160
|
+
if not presented or self.key_resource is None:
|
|
161
|
+
return None
|
|
162
|
+
service = await self.key_resource.get_service({})
|
|
163
|
+
async with service:
|
|
164
|
+
find = getattr(service, "find_by_key", None)
|
|
165
|
+
if find is None: # pragma: no cover - the resource is validated upstream
|
|
166
|
+
return None
|
|
167
|
+
row = await find(hash_api_key(presented))
|
|
168
|
+
if row is None:
|
|
169
|
+
return None
|
|
170
|
+
return row if _key_is_live(row) else None
|
|
171
|
+
|
|
172
|
+
def _principal_for(self, row: Any) -> Principal:
|
|
173
|
+
"""The principal a matched key row resolves to.
|
|
174
|
+
|
|
175
|
+
A DB row's owner wins when present; otherwise the row's own
|
|
176
|
+
``principal_id`` names a service principal, falling back to the
|
|
177
|
+
authenticator's fixed :attr:`principal_id`. The row's
|
|
178
|
+
credential-carried ``roles`` are populated with no extra lookup.
|
|
179
|
+
"""
|
|
180
|
+
owner = getattr(row, "user_id", None)
|
|
181
|
+
roles = roles_from_credential(getattr(row, "roles", None))
|
|
182
|
+
if owner is not None:
|
|
183
|
+
return Principal(id=owner, kind=PrincipalKind.USER, roles=roles)
|
|
184
|
+
# A present-but-unparseable id degrades to ``None`` rather than adopting
|
|
185
|
+
# the shared fixed principal, so a malformed binding cannot grant
|
|
186
|
+
# another principal's scope.
|
|
187
|
+
raw_principal_id = getattr(row, "principal_id", None)
|
|
188
|
+
principal_id = (
|
|
189
|
+
_as_uuid(raw_principal_id) if raw_principal_id is not None else self.principal_id
|
|
190
|
+
)
|
|
191
|
+
return Principal(id=principal_id, kind=PrincipalKind.SERVICE, roles=roles)
|
|
192
|
+
|
|
193
|
+
async def _principal_is_active(self, principal: Principal) -> bool:
|
|
194
|
+
"""Whether ``principal`` is a live user in the configured principal store.
|
|
195
|
+
|
|
196
|
+
Delegates to the shared check so the API-key and OAuth methods enforce the
|
|
197
|
+
identical store rule.
|
|
198
|
+
"""
|
|
199
|
+
return await principal_is_active(self.user_resource, principal)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _reject() -> HTTPException:
|
|
203
|
+
"""The 401 raised when a presented key is missing, invalid, or not a live principal."""
|
|
204
|
+
return HTTPException(
|
|
205
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
206
|
+
detail="Invalid or missing API key.",
|
|
207
|
+
headers={"WWW-Authenticate": API_KEY_CHALLENGE},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _as_uuid(value: Any) -> UUID | None:
|
|
212
|
+
"""Coerce a stored principal id (a string in config) to a ``UUID``, or ``None``."""
|
|
213
|
+
if value is None or isinstance(value, UUID):
|
|
214
|
+
return value
|
|
215
|
+
try:
|
|
216
|
+
return UUID(str(value))
|
|
217
|
+
except ValueError:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _key_is_live(row: Any, *, now: datetime | None = None) -> bool:
|
|
222
|
+
"""Whether a stored key row is usable: ``active`` and not past ``expires_at``.
|
|
223
|
+
|
|
224
|
+
A row that does not carry these columns (the config-list entry type) is
|
|
225
|
+
treated as live.
|
|
226
|
+
"""
|
|
227
|
+
active = getattr(row, "active", None)
|
|
228
|
+
if active is not None and not active:
|
|
229
|
+
return False
|
|
230
|
+
expires_at = getattr(row, "expires_at", None)
|
|
231
|
+
if expires_at is not None:
|
|
232
|
+
current = now or datetime.now(UTC)
|
|
233
|
+
if expires_at.tzinfo is None:
|
|
234
|
+
expires_at = expires_at.replace(tzinfo=UTC)
|
|
235
|
+
if expires_at <= current:
|
|
236
|
+
return False
|
|
237
|
+
return True
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""The API-key resources and key-generation helpers (issue #118).
|
|
2
|
+
|
|
3
|
+
Two key sources, declared once with **full storage truth** and exposed through a
|
|
4
|
+
narrowing :class:`~resourcey.view.resource_view.ResourceView`:
|
|
5
|
+
|
|
6
|
+
* the **DB-backed** resource — an ORM ``ApiKey`` row minted / revoked through the
|
|
7
|
+
ordinary REST surface;
|
|
8
|
+
* the **config-list** resource — a read-only :class:`ListResource` built from
|
|
9
|
+
:class:`~resourcey.auth.auth_config.ApiKeysConfig`.
|
|
10
|
+
|
|
11
|
+
Both store the key **only as a SHA-256 digest**, declared as
|
|
12
|
+
:class:`~pydantic.SecretStr` so the model layer treats it as a secret even though
|
|
13
|
+
the stored value is already a digest. A presented key is validated by hashing it
|
|
14
|
+
and searching for that hash — never through the public query surface, which the
|
|
15
|
+
view closes.
|
|
16
|
+
|
|
17
|
+
The raw key is disclosed exactly once, in the ``201`` create response, through
|
|
18
|
+
the ``expose_secrets`` serialization context. ``in_create_request`` must be
|
|
19
|
+
``False``: otherwise a client could supply the digest the lookup would match.
|
|
20
|
+
|
|
21
|
+
This module is part of ``resourcey.auth``; it imports only lower framework layers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import secrets
|
|
28
|
+
import string
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from typing import Any
|
|
31
|
+
from uuid import UUID, uuid4
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
34
|
+
from sqlalchemy import JSON, Boolean, DateTime, String
|
|
35
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
36
|
+
|
|
37
|
+
from resourcey.auth.auth_config import ApiKeysConfig
|
|
38
|
+
from resourcey.core.resource import Resource
|
|
39
|
+
from resourcey.list.list_resource import ListResource
|
|
40
|
+
from resourcey.sql.sql_resource import SqlResource
|
|
41
|
+
from resourcey.view.resource_view import ResourceView
|
|
42
|
+
|
|
43
|
+
# The name of the secret-bearing field, shared with the service that reveals it.
|
|
44
|
+
KEY_FIELD = "key"
|
|
45
|
+
|
|
46
|
+
API_KEY_RANDOM_BITS = 256
|
|
47
|
+
|
|
48
|
+
# A prefix that makes a leaked key recognisable to secret scanners.
|
|
49
|
+
API_KEY_FORMAT = "rsk_{secret}"
|
|
50
|
+
|
|
51
|
+
_BASE36_ALPHABET = string.digits + string.ascii_lowercase
|
|
52
|
+
|
|
53
|
+
# ceil(API_KEY_RANDOM_BITS / log2(36)); padding keeps a key's length constant.
|
|
54
|
+
_BASE36_WIDTH = 50
|
|
55
|
+
|
|
56
|
+
# SHA-256 hex is 64 characters.
|
|
57
|
+
_KEY_HASH_LENGTH = 64
|
|
58
|
+
|
|
59
|
+
# The key is never read, searched, updated, or client-supplied — only the
|
|
60
|
+
# create response reveals it.
|
|
61
|
+
KEY_EXPOSED: dict[str, dict[str, Any]] = {
|
|
62
|
+
KEY_FIELD: {
|
|
63
|
+
"in_create_request": False,
|
|
64
|
+
"in_create_response": True,
|
|
65
|
+
"in_read_response": False,
|
|
66
|
+
"in_search_response": False,
|
|
67
|
+
"in_update_request": False,
|
|
68
|
+
"in_update_response": False,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# The read-only variant: the key and the internal credential bindings
|
|
73
|
+
# (``principal_id`` / ``roles``) are hidden from every response and the query
|
|
74
|
+
# surface.
|
|
75
|
+
KEY_QUERY_SURFACE_HIDDEN: dict[str, dict[str, Any]] = {
|
|
76
|
+
KEY_FIELD: {"in_read_response": False, "in_search_response": False},
|
|
77
|
+
"principal_id": {"in_read_response": False, "in_search_response": False},
|
|
78
|
+
"roles": {"in_read_response": False, "in_search_response": False},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# The DB-backed view is the full key surface: only the secret ``key`` is
|
|
82
|
+
# projected away, so ``roles`` stays writable. That makes this resource a
|
|
83
|
+
# **privilege-assignment surface** — access must be restricted to administrators.
|
|
84
|
+
STORED_KEY_EXPOSED: dict[str, dict[str, Any]] = dict(KEY_EXPOSED)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def encode_base36(value: int, width: int) -> str:
|
|
88
|
+
"""Render ``value`` in base 36, zero-padded to ``width`` characters."""
|
|
89
|
+
digits: list[str] = []
|
|
90
|
+
while value > 0:
|
|
91
|
+
value, remainder = divmod(value, 36)
|
|
92
|
+
digits.append(_BASE36_ALPHABET[remainder])
|
|
93
|
+
return "".join(reversed(digits)).rjust(width, _BASE36_ALPHABET[0])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def generate_api_key(key_format: str = API_KEY_FORMAT) -> str:
|
|
97
|
+
"""Mint a key: a fresh random secret rendered into ``key_format``.
|
|
98
|
+
|
|
99
|
+
The secret is :data:`API_KEY_RANDOM_BITS` bits from a CSPRNG, so keys are
|
|
100
|
+
unguessable and collisions are not a practical concern.
|
|
101
|
+
"""
|
|
102
|
+
secret = int.from_bytes(secrets.token_bytes(API_KEY_RANDOM_BITS // 8), "big")
|
|
103
|
+
return key_format.format(secret=encode_base36(secret, _BASE36_WIDTH))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def hash_api_key(raw: str) -> str:
|
|
107
|
+
"""The SHA-256 hex digest of a raw key — the value stored at rest.
|
|
108
|
+
|
|
109
|
+
A presented key is hashed and the digest looked up, so neither the table nor
|
|
110
|
+
the config list holds a usable credential.
|
|
111
|
+
"""
|
|
112
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def utc_now() -> datetime:
|
|
116
|
+
"""Default factory for the ``created_at`` / ``updated_at`` columns."""
|
|
117
|
+
return datetime.now(UTC)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ApiKeyBase(DeclarativeBase):
|
|
121
|
+
"""The declarative base owning the ``api_keys`` table (an app points Alembic at it)."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ApiKey(ApiKeyBase):
|
|
125
|
+
"""An API key stored as a row, mintable and revocable at runtime.
|
|
126
|
+
|
|
127
|
+
The ORM attribute is ``key``; the DB column is ``key_hash`` and stores the
|
|
128
|
+
SHA-256 digest, declared as :class:`~pydantic.SecretStr` so the derived DTO
|
|
129
|
+
field is a secret. The create response is the only place the key is
|
|
130
|
+
disclosed, and even there it is the *raw* minted value.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
__tablename__ = "api_keys"
|
|
134
|
+
|
|
135
|
+
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
|
136
|
+
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
137
|
+
key: Mapped[SecretStr] = mapped_column(
|
|
138
|
+
"key_hash", String(_KEY_HASH_LENGTH), nullable=False, unique=True, index=True
|
|
139
|
+
)
|
|
140
|
+
user_id: Mapped[UUID | None] = mapped_column(nullable=True, index=True)
|
|
141
|
+
roles: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
|
142
|
+
active: Mapped[bool] = mapped_column(
|
|
143
|
+
Boolean, nullable=False, default=True, server_default="true"
|
|
144
|
+
)
|
|
145
|
+
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
|
146
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
147
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
|
148
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
149
|
+
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class StoredApiKeyResource(SqlResource[Any, Any]):
|
|
154
|
+
"""The DB-backed key resource: the ``ApiKey`` model, with ``find_by_key``.
|
|
155
|
+
|
|
156
|
+
Builds a ``StoredApiKeyService`` (which adds ``find_by_key`` and the
|
|
157
|
+
one-time reveal). The import is deferred to :meth:`make_service` to avoid a
|
|
158
|
+
module cycle.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def make_service(self, ctx: Any, session_factory: Any) -> Any:
|
|
162
|
+
from resourcey.auth.auth_api_key_service import StoredApiKeyService
|
|
163
|
+
|
|
164
|
+
return StoredApiKeyService(self, ctx, session_factory)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ConfigApiKeyResource(ListResource[Any, Any]):
|
|
168
|
+
"""The read-only config key resource, with ``find_by_key``."""
|
|
169
|
+
|
|
170
|
+
def make_service(self, ctx: Any, items: list[Any]) -> Any:
|
|
171
|
+
from resourcey.auth.auth_api_key_service import ConfigApiKeyService
|
|
172
|
+
|
|
173
|
+
return ConfigApiKeyService(self, ctx, items)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def stored_api_key_resource(
|
|
177
|
+
*,
|
|
178
|
+
session_factory: Any = None,
|
|
179
|
+
session_manager: Any = None,
|
|
180
|
+
name: str | None = None,
|
|
181
|
+
path: str | None = None,
|
|
182
|
+
) -> Resource[Any, Any]:
|
|
183
|
+
"""The DB-backed key resource: the ``ApiKey`` ORM model with full storage truth.
|
|
184
|
+
|
|
185
|
+
This is the **inner** resource the authenticator holds to reach
|
|
186
|
+
``find_by_key``; register :func:`stored_api_key_view` in the manifest, not this.
|
|
187
|
+
"""
|
|
188
|
+
return StoredApiKeyResource(
|
|
189
|
+
ApiKey,
|
|
190
|
+
session_factory=session_factory,
|
|
191
|
+
session_manager=session_manager,
|
|
192
|
+
name=name,
|
|
193
|
+
path=path or "api-keys",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def stored_api_key_view(inner: Resource[Any, Any]) -> ResourceView[Any, Any]:
|
|
198
|
+
"""The exposed view over a DB-backed key resource (the one-time reveal)."""
|
|
199
|
+
return ResourceView(inner, exposed_field_overrides=STORED_KEY_EXPOSED)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ConfigApiKey(BaseModel):
|
|
203
|
+
"""A served config key: the identifier, the label, the digest, and its roles.
|
|
204
|
+
|
|
205
|
+
Only ``id`` / ``name`` are ever exposed; ``key`` holds the digest and is
|
|
206
|
+
hidden by the read-hiding view, so the authenticator can search it but never
|
|
207
|
+
read it.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
id: str
|
|
211
|
+
name: str | None = None
|
|
212
|
+
key: SecretStr
|
|
213
|
+
principal_id: str | None = None
|
|
214
|
+
roles: list[str] = Field(default_factory=list)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def config_api_key_models(config: ApiKeysConfig) -> list[ConfigApiKey]:
|
|
218
|
+
"""The served config entries, with each plaintext key hashed on load.
|
|
219
|
+
|
|
220
|
+
The plaintext is read once, hashed, and not retained.
|
|
221
|
+
"""
|
|
222
|
+
return [
|
|
223
|
+
ConfigApiKey(
|
|
224
|
+
id=entry.id,
|
|
225
|
+
name=entry.name,
|
|
226
|
+
key=SecretStr(hash_api_key(entry.key.get_secret_value())),
|
|
227
|
+
principal_id=entry.principal_id,
|
|
228
|
+
roles=list(entry.roles),
|
|
229
|
+
)
|
|
230
|
+
for entry in config.api_keys
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def config_api_key_resource(
|
|
235
|
+
config: ApiKeysConfig, *, path: str | None = None
|
|
236
|
+
) -> ConfigApiKeyResource:
|
|
237
|
+
"""The read-only, config-backed key resource over the hashed entries."""
|
|
238
|
+
return ConfigApiKeyResource(
|
|
239
|
+
config_api_key_models(config),
|
|
240
|
+
model=ConfigApiKey,
|
|
241
|
+
path=path or "api-keys",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def config_api_key_view(inner: Resource[Any, Any]) -> ResourceView[Any, Any]:
|
|
246
|
+
"""The exposed view over a config-list key resource (read-only, key hidden).
|
|
247
|
+
|
|
248
|
+
Uses :data:`KEY_QUERY_SURFACE_HIDDEN` (not :data:`KEY_EXPOSED`): the config
|
|
249
|
+
list is read-only, so there is no create route to reveal anything.
|
|
250
|
+
"""
|
|
251
|
+
return ResourceView(inner, exposed_field_overrides=KEY_QUERY_SURFACE_HIDDEN)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The services backing the two API-key resources (issue #118).
|
|
2
|
+
|
|
3
|
+
Both expose the same lookup seam a presented key is validated through::
|
|
4
|
+
|
|
5
|
+
async def find_by_key(self, key_hash: str) -> Any | None
|
|
6
|
+
|
|
7
|
+
``key_hash`` is the SHA-256 digest of the presented key. The lookup is a
|
|
8
|
+
dedicated method, never a :class:`~resourcey.util.search_filter.SearchFilter` —
|
|
9
|
+
the public query surface is closed over the key, so a guess-confirmation oracle
|
|
10
|
+
over the digest must not be reachable through ``?key__eq=``.
|
|
11
|
+
|
|
12
|
+
:class:`StoredApiKeyService` mints a raw key, stores its digest, and reveals the
|
|
13
|
+
raw value once through the ``expose_secrets`` serialization context.
|
|
14
|
+
:class:`ConfigApiKeyService` looks the digest up in the in-process list,
|
|
15
|
+
comparing with :func:`secrets.compare_digest`.
|
|
16
|
+
|
|
17
|
+
This module is part of ``resourcey.auth``; it imports only lower framework layers.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import secrets
|
|
23
|
+
from typing import Any, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from pydantic import SecretStr
|
|
26
|
+
|
|
27
|
+
from resourcey.auth.auth_api_key_resource import KEY_FIELD, generate_api_key, hash_api_key
|
|
28
|
+
from resourcey.list.list_service import ListService
|
|
29
|
+
from resourcey.sql.sql_service import SqlService
|
|
30
|
+
|
|
31
|
+
# The context that makes the create response disclose the freshly minted key.
|
|
32
|
+
EXPOSE_SECRETS: dict[str, Any] = {"expose_secrets": True}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class SupportsFindByKey(Protocol):
|
|
37
|
+
"""A service that can look a presented key's digest up without the query surface."""
|
|
38
|
+
|
|
39
|
+
async def find_by_key(self, key_hash: str) -> Any | None:
|
|
40
|
+
"""Return the entry whose stored digest is ``key_hash``, or ``None``."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StoredApiKeyService(SqlService[Any, Any]):
|
|
45
|
+
"""A :class:`SqlService` whose ``create`` mints, stores a digest, and reveals.
|
|
46
|
+
|
|
47
|
+
The stored value is always the SHA-256 digest of a freshly minted key; the
|
|
48
|
+
raw key is disclosed exactly once, in the create response.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
async def create(self, payload: Any) -> Any:
|
|
52
|
+
"""Mint a key, persist its digest, and return the DTO with the raw key.
|
|
53
|
+
|
|
54
|
+
This is the only response that carries the raw value — a caller that
|
|
55
|
+
loses it must mint a replacement.
|
|
56
|
+
"""
|
|
57
|
+
raw = generate_api_key()
|
|
58
|
+
stored = payload.model_copy(update={KEY_FIELD: SecretStr(hash_api_key(raw))})
|
|
59
|
+
created = await super().create(stored)
|
|
60
|
+
self.set_serialization_context(EXPOSE_SECRETS)
|
|
61
|
+
return created.model_copy(update={KEY_FIELD: SecretStr(raw)})
|
|
62
|
+
|
|
63
|
+
async def find_by_key(self, key_hash: str) -> Any | None:
|
|
64
|
+
"""Look the digest up by indexed equality; ``None`` when absent.
|
|
65
|
+
|
|
66
|
+
A direct ``WHERE key_hash = :digest`` through the resource's escape
|
|
67
|
+
hatch, never a ``SearchFilter``, so it is not reachable from the public
|
|
68
|
+
query surface.
|
|
69
|
+
"""
|
|
70
|
+
session = self._active_session()
|
|
71
|
+
column = self._resource.table.c[self._resource.get_column_name(KEY_FIELD)]
|
|
72
|
+
statement = column.table.select().where(column == key_hash)
|
|
73
|
+
row = (await session.execute(statement)).mappings().first()
|
|
74
|
+
if row is None:
|
|
75
|
+
return None
|
|
76
|
+
return self._to_dto(row)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConfigApiKeyService(ListService[Any, Any]):
|
|
80
|
+
"""A :class:`ListService` that can look a digest up in the served list."""
|
|
81
|
+
|
|
82
|
+
async def find_by_key(self, key_hash: str) -> Any | None:
|
|
83
|
+
"""Return the entry whose stored digest equals ``key_hash``, or ``None``.
|
|
84
|
+
|
|
85
|
+
The comparison is constant-time, so a shared prefix does not leak on timing.
|
|
86
|
+
"""
|
|
87
|
+
self._require_entered()
|
|
88
|
+
candidate = key_hash.encode()
|
|
89
|
+
for item in self._items:
|
|
90
|
+
stored = getattr(item, KEY_FIELD, None)
|
|
91
|
+
raw = stored.get_secret_value() if isinstance(stored, SecretStr) else stored
|
|
92
|
+
if raw is not None and secrets.compare_digest(candidate, str(raw).encode()):
|
|
93
|
+
return item
|
|
94
|
+
return None
|