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.
Files changed (111) hide show
  1. resourcey/__init__.py +4 -0
  2. resourcey/__main__.py +48 -0
  3. resourcey/auth/auth_api_key.py +237 -0
  4. resourcey/auth/auth_api_key_resource.py +251 -0
  5. resourcey/auth/auth_api_key_service.py +94 -0
  6. resourcey/auth/auth_authorized_dependency.py +149 -0
  7. resourcey/auth/auth_authorized_service.py +274 -0
  8. resourcey/auth/auth_config.py +75 -0
  9. resourcey/auth/auth_cookie.py +139 -0
  10. resourcey/auth/auth_me_routes.py +147 -0
  11. resourcey/auth/auth_oauth.py +362 -0
  12. resourcey/auth/auth_oauth_client.py +270 -0
  13. resourcey/auth/auth_oauth_config.py +79 -0
  14. resourcey/auth/auth_oauth_provider.py +257 -0
  15. resourcey/auth/auth_oauth_routes.py +474 -0
  16. resourcey/auth/auth_oauth_service.py +85 -0
  17. resourcey/auth/auth_oauth_setup.py +206 -0
  18. resourcey/auth/auth_oauth_token.py +408 -0
  19. resourcey/auth/auth_policy.py +275 -0
  20. resourcey/auth/auth_principal.py +340 -0
  21. resourcey/auth/auth_rbac.py +213 -0
  22. resourcey/auth/auth_rbac_resolver.py +128 -0
  23. resourcey/auth/auth_rbac_store.py +180 -0
  24. resourcey/auth/auth_role.py +124 -0
  25. resourcey/cache/cache_defaults.py +96 -0
  26. resourcey/cache/cache_header.py +83 -0
  27. resourcey/cache/cache_store.py +105 -0
  28. resourcey/cache/cache_strategy.py +257 -0
  29. resourcey/cache/cached_resource.py +177 -0
  30. resourcey/cache/cached_service.py +247 -0
  31. resourcey/config/config_base.py +185 -0
  32. resourcey/config/lazy_field.py +188 -0
  33. resourcey/core/dto.py +748 -0
  34. resourcey/core/errors.py +46 -0
  35. resourcey/core/manifest.py +129 -0
  36. resourcey/core/resource.py +224 -0
  37. resourcey/core/service.py +477 -0
  38. resourcey/encryption/encryption_config.py +70 -0
  39. resourcey/encryption/encryption_service.py +200 -0
  40. resourcey/filestore/file_config.py +46 -0
  41. resourcey/filestore/file_resource.py +476 -0
  42. resourcey/filestore/file_routes.py +325 -0
  43. resourcey/filestore/file_store.py +124 -0
  44. resourcey/filestore/local_file_store.py +200 -0
  45. resourcey/filestore/s3_file_store.py +263 -0
  46. resourcey/filestore/signed_url.py +142 -0
  47. resourcey/filestore/sql_file_store.py +254 -0
  48. resourcey/http/app.py +135 -0
  49. resourcey/http/batch_routes.py +466 -0
  50. resourcey/http/dependency_builder.py +123 -0
  51. resourcey/http/http_config.py +68 -0
  52. resourcey/http/routes.py +957 -0
  53. resourcey/jobs/jobs_config.py +62 -0
  54. resourcey/jobs/jobs_details.py +71 -0
  55. resourcey/jobs/jobs_model.py +227 -0
  56. resourcey/jobs/jobs_runner.py +470 -0
  57. resourcey/list/list_resource.py +291 -0
  58. resourcey/list/list_service.py +270 -0
  59. resourcey/list/pydantic_2_dto.py +84 -0
  60. resourcey/mongo/embedded.py +127 -0
  61. resourcey/mongo/mongo_client.py +170 -0
  62. resourcey/mongo/mongo_config.py +118 -0
  63. resourcey/mongo/mongo_filter_converter.py +363 -0
  64. resourcey/mongo/mongo_resource.py +373 -0
  65. resourcey/mongo/mongo_service.py +481 -0
  66. resourcey/mongo/mongo_sort_converter.py +93 -0
  67. resourcey/py.typed +0 -0
  68. resourcey/realtime/realtime_asyncapi.py +349 -0
  69. resourcey/realtime/realtime_channel.py +120 -0
  70. resourcey/realtime/realtime_config.py +29 -0
  71. resourcey/realtime/realtime_event.py +52 -0
  72. resourcey/realtime/realtime_redis_channel.py +116 -0
  73. resourcey/realtime/realtime_routes.py +386 -0
  74. resourcey/sql/cursor.py +71 -0
  75. resourcey/sql/db_config.py +60 -0
  76. resourcey/sql/filter_converter.py +438 -0
  77. resourcey/sql/model_type.py +64 -0
  78. resourcey/sql/session_manager.py +131 -0
  79. resourcey/sql/sort_converter.py +101 -0
  80. resourcey/sql/sql_config.py +49 -0
  81. resourcey/sql/sql_resource.py +345 -0
  82. resourcey/sql/sql_service.py +453 -0
  83. resourcey/sql/sqlalchemy_2_dto.py +315 -0
  84. resourcey/tasks/cli.py +98 -0
  85. resourcey/tasks/cron.py +231 -0
  86. resourcey/tasks/scheduler.py +200 -0
  87. resourcey/tasks/task.py +148 -0
  88. resourcey/triggers/redis_trigger.py +128 -0
  89. resourcey/triggers/trigger.py +78 -0
  90. resourcey/triggers/trigger_config.py +71 -0
  91. resourcey/triggers/trigger_runner.py +95 -0
  92. resourcey/triggers/triggered_dependency_builder.py +178 -0
  93. resourcey/triggers/triggered_resource.py +185 -0
  94. resourcey/triggers/triggered_service.py +175 -0
  95. resourcey/triggers/webhook_trigger.py +203 -0
  96. resourcey/util/cursor.py +132 -0
  97. resourcey/util/env_parser.py +525 -0
  98. resourcey/util/import_paths.py +50 -0
  99. resourcey/util/missing.py +60 -0
  100. resourcey/util/models.py +367 -0
  101. resourcey/util/naming.py +57 -0
  102. resourcey/util/search_filter.py +510 -0
  103. resourcey/util/secret_serialization.py +76 -0
  104. resourcey/util/singleton.py +113 -0
  105. resourcey/util/sort_order.py +96 -0
  106. resourcey/view/resource_view.py +361 -0
  107. resourcey/view/view_service.py +136 -0
  108. resourcey-0.0.1.dist-info/METADATA +504 -0
  109. resourcey-0.0.1.dist-info/RECORD +111 -0
  110. resourcey-0.0.1.dist-info/WHEEL +4 -0
  111. resourcey-0.0.1.dist-info/licenses/LICENSE +21 -0
resourcey/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """resourcey — a resource-oriented Python web framework.
2
+
3
+ Progressive enhancement with escape hatches to FastAPI, SQLAlchemy, and Alembic.
4
+ """
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