sysnet-apikeys 0.2.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.
@@ -0,0 +1,33 @@
1
+ """sysnet-apikeys: správa API klíčů FastAPI služeb (ADR 006 v CML)."""
2
+
3
+ from sysnet_apikeys.errors import (
4
+ ApiKeyError,
5
+ AuthenticationError,
6
+ AuthorizationError,
7
+ ConflictError,
8
+ NotFoundError,
9
+ ReadOnlyStoreError,
10
+ )
11
+ from sysnet_apikeys.models import ALL_SCOPES, AuditEvent, IssuedKey, KeyRecord, Principal
12
+ from sysnet_apikeys.service import ApiKeyService
13
+ from sysnet_apikeys.store import InMemoryStore, KeyStore
14
+
15
+ __version__ = "0.2.0"
16
+
17
+ __all__ = [
18
+ "ALL_SCOPES",
19
+ "ApiKeyError",
20
+ "ApiKeyService",
21
+ "AuditEvent",
22
+ "AuthenticationError",
23
+ "AuthorizationError",
24
+ "ConflictError",
25
+ "InMemoryStore",
26
+ "IssuedKey",
27
+ "KeyRecord",
28
+ "KeyStore",
29
+ "NotFoundError",
30
+ "Principal",
31
+ "ReadOnlyStoreError",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,140 @@
1
+ """Admin API pro správu klíčů (volitelné: ``sysnet-apikeys[fastapi]``).
2
+
3
+ Stejné cesty jako notificator: ``POST/GET /keys``, ``POST /keys/revoke``; navíc ``POST /keys/rotate``
4
+ a ``GET /keys/unused``. Chrání je statický ``X-Admin-Key`` porovnávaný v konstantním čase. Bez nastaveného
5
+ secretu vrací 503, takže je admin API vypnuté, dokud ho někdo vědomě nezapne.
6
+
7
+ Bez ``from __future__ import annotations``: FastAPI musí anotace vyhodnotit za běhu (viz fastapi.py).
8
+ """
9
+
10
+ import secrets
11
+ from datetime import datetime, timedelta
12
+ from typing import Annotated, Any
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
15
+ from fastapi.security import APIKeyHeader
16
+ from pydantic import BaseModel, Field, model_validator
17
+
18
+ from sysnet_apikeys.errors import AuthenticationError
19
+ from sysnet_apikeys.models import KeyRecord
20
+ from sysnet_apikeys.service import ApiKeyService
21
+
22
+ ADMIN_KEY_HEADER = "X-Admin-Key"
23
+ ADMIN_ACTOR = "admin"
24
+ _scheme = APIKeyHeader(name=ADMIN_KEY_HEADER, scheme_name="AdminKeyAuth", auto_error=False)
25
+
26
+
27
+ class KeyOut(BaseModel):
28
+ """Popis klíče. Nikdy neobsahuje otevřený klíč ani jeho hash."""
29
+
30
+ public_id: str
31
+ client: str
32
+ scopes: list[str]
33
+ agenda: str | None
34
+ status: str
35
+ created_at: datetime
36
+ expires_at: datetime | None
37
+ last_used_at: datetime | None
38
+ created_by: str | None
39
+
40
+
41
+ class IssuedKeyOut(KeyOut):
42
+ key: str = Field(description="Předejte klientovi bezpečným kanálem; už se nezobrazí.")
43
+
44
+
45
+ class CreateIn(BaseModel):
46
+ name: str = Field(min_length=1, max_length=200)
47
+ scopes: list[str] = Field(min_length=1)
48
+ agenda: str | None = None
49
+ expires_at: datetime | None = None
50
+
51
+
52
+ class RotateIn(BaseModel):
53
+ name: str = Field(min_length=1)
54
+ grace_seconds: int = Field(ge=0, le=60 * 60 * 24 * 365)
55
+ scopes: list[str] | None = Field(default=None, min_length=1)
56
+ expires_at: datetime | None = None
57
+
58
+
59
+ class RevokeIn(BaseModel):
60
+ name: str | None = None
61
+ public_id: str | None = None
62
+
63
+ @model_validator(mode="after")
64
+ def _exactly_one(self) -> "RevokeIn":
65
+ if bool(self.name) == bool(self.public_id):
66
+ raise ValueError("Zadejte právě jedno z: name, public_id.")
67
+ return self
68
+
69
+
70
+ class RevokedOut(BaseModel):
71
+ revoked: int
72
+
73
+
74
+ def _out(service: ApiKeyService, record: KeyRecord) -> dict[str, Any]:
75
+ return {
76
+ "public_id": record.public_id,
77
+ "client": record.client,
78
+ "scopes": sorted(record.scopes),
79
+ "agenda": record.agenda,
80
+ "status": service.describe(record),
81
+ "created_at": record.created_at,
82
+ "expires_at": record.expires_at,
83
+ "last_used_at": record.last_used_at,
84
+ "created_by": record.created_by,
85
+ }
86
+
87
+
88
+ def make_admin_router(service: ApiKeyService, *, admin_key: str | None) -> APIRouter:
89
+ """Vyrobí router; ``admin_key=None`` = admin API vypnuté (503)."""
90
+
91
+ def require_admin(raw: Annotated[str | None, Security(_scheme)] = None) -> None:
92
+ if not admin_key:
93
+ raise HTTPException(
94
+ status.HTTP_503_SERVICE_UNAVAILABLE, "Administrace klíčů není nakonfigurovaná."
95
+ )
96
+ if not secrets.compare_digest((raw or "").encode(), admin_key.encode()):
97
+ raise AuthenticationError("Neplatný administrační secret.")
98
+
99
+ router = APIRouter(tags=["api-keys"], dependencies=[Depends(require_admin)])
100
+
101
+ @router.post("/keys", status_code=status.HTTP_201_CREATED, response_model=IssuedKeyOut)
102
+ async def create_key(body: CreateIn) -> dict[str, Any]:
103
+ issued = await service.issue(
104
+ body.name,
105
+ scopes=body.scopes,
106
+ agenda=body.agenda,
107
+ expires_at=body.expires_at,
108
+ created_by=ADMIN_ACTOR,
109
+ )
110
+ return {**_out(service, issued.record), "key": issued.key}
111
+
112
+ @router.get("/keys", response_model=list[KeyOut])
113
+ async def list_keys(client: str | None = None) -> list[dict[str, Any]]:
114
+ return [_out(service, r) for r in await service.list_keys(client)]
115
+
116
+ @router.get("/keys/unused", response_model=list[KeyOut])
117
+ async def unused_keys(older_than_days: Annotated[int, Query(ge=0)] = 30) -> list[dict[str, Any]]:
118
+ records = await service.unused(older_than=timedelta(days=older_than_days))
119
+ return [_out(service, r) for r in records]
120
+
121
+ @router.post("/keys/rotate", status_code=status.HTTP_201_CREATED, response_model=IssuedKeyOut)
122
+ async def rotate_key(body: RotateIn) -> dict[str, Any]:
123
+ issued = await service.rotate(
124
+ body.name,
125
+ grace=timedelta(seconds=body.grace_seconds),
126
+ scopes=body.scopes,
127
+ expires_at=body.expires_at,
128
+ actor=ADMIN_ACTOR,
129
+ )
130
+ return {**_out(service, issued.record), "key": issued.key}
131
+
132
+ @router.post("/keys/revoke", response_model=RevokedOut)
133
+ async def revoke_key(body: RevokeIn) -> dict[str, int]:
134
+ if body.public_id:
135
+ await service.revoke_key(body.public_id, actor=ADMIN_ACTOR)
136
+ return {"revoked": 1}
137
+ assert body.name is not None
138
+ return {"revoked": await service.revoke(body.name, actor=ADMIN_ACTOR)}
139
+
140
+ return router
sysnet_apikeys/cli.py ADDED
@@ -0,0 +1,213 @@
1
+ """Příkazová řádka: ``sysnet-apikeys issue|rotate|revoke|list|unused|import-yaml``.
2
+
3
+ Úložiště se volí proměnnými prostředí (žádné heslo na příkazové řádce):
4
+
5
+ - ``APIKEYS_STORE=mongo`` + ``APIKEYS_MONGO_URL``, ``APIKEYS_MONGO_DB``,
6
+ ``APIKEYS_MONGO_COLLECTION`` (výchozí ``api_keys``)
7
+ - ``APIKEYS_STORE=sql`` + ``APIKEYS_SQL_URL`` (např. ``postgresql+asyncpg://...``),
8
+ ``APIKEYS_SQL_TABLE`` (výchozí ``api_keys``)
9
+
10
+ Otevřený klíč se tiskne jen při ``issue`` a ``rotate`` a jen na stdout.
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ from collections.abc import AsyncIterator, Awaitable, Callable
17
+ from contextlib import asynccontextmanager
18
+ from dataclasses import replace
19
+ from datetime import timedelta
20
+ from pathlib import Path
21
+ from typing import Annotated, Any, TypeVar
22
+
23
+ import typer
24
+ from sysnet_pyutils.utils import gmt_now
25
+
26
+ from sysnet_apikeys.errors import ApiKeyError, ConflictError
27
+ from sysnet_apikeys.legacy import LegacyYamlStore
28
+ from sysnet_apikeys.models import KeyRecord
29
+ from sysnet_apikeys.service import ApiKeyService
30
+ from sysnet_apikeys.store import KeyStore
31
+
32
+ app = typer.Typer(add_completion=False, no_args_is_help=True, help="Správa API klíčů SYSNET.")
33
+ T = TypeVar("T")
34
+
35
+
36
+ def _env(name: str, default: str | None = None) -> str:
37
+ value = os.environ.get(name, default)
38
+ if not value:
39
+ typer.echo(f"Chybí proměnná prostředí {name}.", err=True)
40
+ raise typer.Exit(2)
41
+ return value
42
+
43
+
44
+ @asynccontextmanager
45
+ async def _open_store() -> AsyncIterator[KeyStore]:
46
+ kind = _env("APIKEYS_STORE")
47
+ if kind == "mongo":
48
+ from pymongo import AsyncMongoClient
49
+
50
+ from sysnet_apikeys.mongo import MongoStore
51
+
52
+ client: AsyncMongoClient[Any] = AsyncMongoClient(_env("APIKEYS_MONGO_URL"))
53
+ collection = _env("APIKEYS_MONGO_COLLECTION", "api_keys")
54
+ store = MongoStore(client[_env("APIKEYS_MONGO_DB")], collection=collection)
55
+ await store.create_schema()
56
+ try:
57
+ yield store
58
+ finally:
59
+ await client.close()
60
+ elif kind == "sql":
61
+ from sqlalchemy.ext.asyncio import create_async_engine
62
+
63
+ from sysnet_apikeys.sql import SqlStore
64
+
65
+ engine = create_async_engine(_env("APIKEYS_SQL_URL"))
66
+ sql_store = SqlStore(engine, table=_env("APIKEYS_SQL_TABLE", "api_keys"))
67
+ await sql_store.create_schema()
68
+ try:
69
+ yield sql_store
70
+ finally:
71
+ await engine.dispose()
72
+ else:
73
+ typer.echo(f"APIKEYS_STORE musí být 'mongo' nebo 'sql', ne '{kind}'.", err=True)
74
+ raise typer.Exit(2)
75
+
76
+
77
+ def _run(action: Callable[[ApiKeyService, KeyStore], Awaitable[T]]) -> T:
78
+ async def main() -> T:
79
+ async with _open_store() as store:
80
+ return await action(ApiKeyService(store), store)
81
+
82
+ try:
83
+ return asyncio.run(main())
84
+ except ApiKeyError as error:
85
+ typer.echo(f"Chyba: {error}", err=True)
86
+ raise typer.Exit(1) from error
87
+
88
+
89
+ def _row(service: ApiKeyService, record: KeyRecord) -> dict[str, Any]:
90
+ return {
91
+ "public_id": record.public_id,
92
+ "client": record.client,
93
+ "scopes": sorted(record.scopes),
94
+ "agenda": record.agenda,
95
+ "status": service.describe(record),
96
+ "created_at": record.created_at.isoformat(),
97
+ "expires_at": record.expires_at.isoformat() if record.expires_at else None,
98
+ "last_used_at": record.last_used_at.isoformat() if record.last_used_at else None,
99
+ }
100
+
101
+
102
+ def _print_rows(service: ApiKeyService, records: list[KeyRecord], as_json: bool) -> None:
103
+ rows = [_row(service, r) for r in records]
104
+ if as_json:
105
+ typer.echo(json.dumps(rows, ensure_ascii=False))
106
+ return
107
+ for r in rows:
108
+ typer.echo(f"{r['public_id']} {r['client']:<28} {r['status']:<8} agenda={r['agenda'] or '-'} "
109
+ f"scopes={','.join(r['scopes'])} last_used={r['last_used_at'] or '-'}")
110
+
111
+
112
+ @app.command()
113
+ def issue(
114
+ client: str,
115
+ scope: Annotated[list[str], typer.Option("--scope", help="Oprávnění (lze opakovat).")],
116
+ agenda: Annotated[str | None, typer.Option()] = None,
117
+ ) -> None:
118
+ """Vydá první klíč klienta. Klíč se zobrazí jen teď."""
119
+
120
+ async def action(service: ApiKeyService, _: KeyStore) -> str:
121
+ return (await service.issue(client, scopes=scope, agenda=agenda, created_by="cli")).key
122
+
123
+ typer.echo(_run(action))
124
+
125
+
126
+ @app.command()
127
+ def rotate(
128
+ client: str,
129
+ grace_hours: Annotated[float, typer.Option("--grace-hours", min=0)] = 24,
130
+ ) -> None:
131
+ """Vydá nový klíč; starý platí ještě ``--grace-hours``."""
132
+
133
+ async def action(service: ApiKeyService, _: KeyStore) -> str:
134
+ issued = await service.rotate(client, grace=timedelta(hours=grace_hours), actor="cli")
135
+ return issued.key
136
+
137
+ typer.echo(_run(action))
138
+
139
+
140
+ @app.command()
141
+ def revoke(client: str) -> None:
142
+ """Zruší všechny aktivní klíče klienta."""
143
+
144
+ async def action(service: ApiKeyService, _: KeyStore) -> int:
145
+ return await service.revoke(client, actor="cli")
146
+
147
+ typer.echo(f"Zrušeno klíčů: {_run(action)}")
148
+
149
+
150
+ @app.command("list")
151
+ def list_(
152
+ client: Annotated[str | None, typer.Option()] = None,
153
+ as_json: Annotated[bool, typer.Option("--json")] = False,
154
+ ) -> None:
155
+ """Vypíše klíče (nikdy ne otevřený klíč ani hash)."""
156
+
157
+ async def action(service: ApiKeyService, _: KeyStore) -> tuple[ApiKeyService, list[KeyRecord]]:
158
+ return service, await service.list_keys(client)
159
+
160
+ service, records = _run(action)
161
+ _print_rows(service, records, as_json)
162
+
163
+
164
+ @app.command()
165
+ def unused(
166
+ older_than_days: Annotated[int, typer.Option("--older-than-days", min=0)] = 30,
167
+ as_json: Annotated[bool, typer.Option("--json")] = False,
168
+ ) -> None:
169
+ """Aktivní klíče, které nikdo nepoužil déle než zadaný počet dní (nebo nikdy)."""
170
+
171
+ async def action(service: ApiKeyService, _: KeyStore) -> tuple[ApiKeyService, list[KeyRecord]]:
172
+ return service, await service.unused(older_than=timedelta(days=older_than_days))
173
+
174
+ service, records = _run(action)
175
+ _print_rows(service, records, as_json)
176
+
177
+
178
+ @app.command("import-yaml")
179
+ def import_yaml(
180
+ path: Path,
181
+ apply: Annotated[bool, typer.Option("--apply", help="Bez tohoto přepínače jen dry-run.")] = False,
182
+ ) -> None:
183
+ """Převede ``api_keys`` ze starého YAML na hashe v úložišti. Klienti zůstanou beze změny."""
184
+ legacy = LegacyYamlStore.from_file(path)
185
+
186
+ async def action(service: ApiKeyService, store: KeyStore) -> tuple[int, int, int]:
187
+ added = skipped = 0
188
+ records = await legacy.list_all()
189
+ for old in records:
190
+ if await store.get(old.key_hash) is not None:
191
+ skipped += 1
192
+ continue
193
+ if apply:
194
+ try:
195
+ imported = replace(old, created_at=gmt_now(), legacy=False, created_by="import-yaml")
196
+ await store.add(imported)
197
+ except ConflictError:
198
+ skipped += 1
199
+ continue
200
+ added += 1
201
+ return len(records), added, skipped
202
+
203
+ total, added, skipped = _run(action)
204
+ mode = "" if apply else " (dry-run, nic se nezapsalo; použijte --apply)"
205
+ typer.echo(f"Nalezeno: {total}, přidáno: {added}, přeskočeno: {skipped}{mode}")
206
+
207
+
208
+ def main() -> None:
209
+ app()
210
+
211
+
212
+ if __name__ == "__main__":
213
+ main()
@@ -0,0 +1,29 @@
1
+ """Výjimky knihovny. Služba je překládá na HTTP odpovědi, knihovna sama HTTP nezná."""
2
+
3
+
4
+ class ApiKeyError(Exception):
5
+ """Základ všech chyb knihovny."""
6
+
7
+
8
+ class AuthenticationError(ApiKeyError):
9
+ """Klíč chybí, je neznámý, zrušený nebo propadlý. Příčina se záměrně nerozlišuje."""
10
+
11
+
12
+ class AuthorizationError(ApiKeyError):
13
+ """Klíč je platný, ale nemá požadované oprávnění."""
14
+
15
+ def __init__(self, missing: frozenset[str]) -> None:
16
+ super().__init__("Chybí oprávnění: " + ", ".join(sorted(missing)))
17
+ self.missing = missing
18
+
19
+
20
+ class ConflictError(ApiKeyError):
21
+ """Klient nebo klíč už existuje."""
22
+
23
+
24
+ class NotFoundError(ApiKeyError):
25
+ """Klient nebo klíč neexistuje (nebo už není aktivní)."""
26
+
27
+
28
+ class ReadOnlyStoreError(ApiKeyError):
29
+ """Úložiště je jen pro čtení (např. starý YAML)."""
@@ -0,0 +1,71 @@
1
+ """FastAPI vazba (volitelná: ``pip install sysnet-apikeys[fastapi]``).
2
+
3
+ Bez ``from __future__ import annotations``: FastAPI musí anotace vnořených dependency
4
+ (``Depends(self)``) vyhodnotit za běhu, což se zpožděnými anotacemi nepodaří (vrací 422).
5
+ """
6
+
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Annotated
9
+
10
+ from fastapi import Depends, FastAPI, Request, Security
11
+ from fastapi.responses import JSONResponse
12
+ from fastapi.security import APIKeyHeader
13
+
14
+ from sysnet_apikeys.errors import (
15
+ AuthenticationError,
16
+ AuthorizationError,
17
+ ConflictError,
18
+ NotFoundError,
19
+ ReadOnlyStoreError,
20
+ )
21
+ from sysnet_apikeys.models import Principal
22
+ from sysnet_apikeys.service import ApiKeyService
23
+
24
+ API_KEY_HEADER = "X-API-KEY"
25
+ _scheme = APIKeyHeader(name=API_KEY_HEADER, scheme_name="ApiKeyAuth", auto_error=False)
26
+
27
+
28
+ class ApiKeyAuth:
29
+ """Dependency: ``Depends(auth)`` vrátí :class:`Principal`; ``auth.scopes(...)`` přidá oprávnění.
30
+
31
+ ``auto_error=False``: chybějící hlavička končí stejnou 401 jako neplatný klíč
32
+ (FastAPI by jinak vrátilo 403 v jiném tvaru a prozradilo, že hlavička chyběla).
33
+ """
34
+
35
+ def __init__(self, service: ApiKeyService) -> None:
36
+ self.service = service
37
+
38
+ async def __call__(self, raw_key: Annotated[str | None, Security(_scheme)] = None) -> Principal:
39
+ return await self.service.authenticate(raw_key)
40
+
41
+ def scopes(self, *required: str) -> Callable[[Principal], Awaitable[Principal]]:
42
+ async def dependency(principal: Annotated[Principal, Depends(self)]) -> Principal:
43
+ principal.require_scopes(*required)
44
+ return principal
45
+
46
+ return dependency
47
+
48
+
49
+ def install_error_handlers(app: FastAPI) -> None:
50
+ """Překlad výjimek knihovny na 401/403 se stručným tvarem ``{"detail": ...}``."""
51
+
52
+ @app.exception_handler(AuthenticationError)
53
+ async def _unauthenticated(_: Request, exc: AuthenticationError) -> JSONResponse:
54
+ return JSONResponse({"detail": str(exc)}, status_code=401)
55
+
56
+ @app.exception_handler(AuthorizationError)
57
+ async def _forbidden(_: Request, exc: AuthorizationError) -> JSONResponse:
58
+ # Které oprávnění chybí, se klientovi neříká; zůstává v logu služby.
59
+ return JSONResponse({"detail": "Nedostatečné oprávnění."}, status_code=403)
60
+
61
+ @app.exception_handler(ConflictError)
62
+ async def _conflict(_: Request, exc: ConflictError) -> JSONResponse:
63
+ return JSONResponse({"detail": str(exc)}, status_code=409)
64
+
65
+ @app.exception_handler(NotFoundError)
66
+ async def _not_found(_: Request, exc: NotFoundError) -> JSONResponse:
67
+ return JSONResponse({"detail": str(exc)}, status_code=404)
68
+
69
+ @app.exception_handler(ReadOnlyStoreError)
70
+ async def _read_only(_: Request, exc: ReadOnlyStoreError) -> JSONResponse:
71
+ return JSONResponse({"detail": str(exc)}, status_code=409)
@@ -0,0 +1,93 @@
1
+ """Čtení starého YAML ``api_keys: [{klíč: jméno}]`` bez změny klientů.
2
+
3
+ Výsledek je stejný jako dnešní ``Context.check_api_key`` v sysnet-pyutils (agenda,
4
+ jméno uživatele), ale bez sdíleného stavu: identita se vrací hodnotou.
5
+ Úložiště je jen pro čtení; zápisy (vydání, rotace, rušení) vrací ``ReadOnlyStoreError``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from sysnet_apikeys.errors import ReadOnlyStoreError
19
+ from sysnet_apikeys.models import ALL_SCOPES, KeyRecord
20
+ from sysnet_apikeys.security import hash_key
21
+
22
+ CONFIG_API_KEYS = "api_keys"
23
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class LegacyItem:
28
+ """Jedna položka starého YAML po zahashování; otevřený klíč zde už není."""
29
+
30
+ key_hash: str
31
+ agenda: str
32
+ client: str
33
+
34
+
35
+ def hash_legacy_config(config: Mapping[str, Any]) -> list[LegacyItem]:
36
+ """Projde konfiguraci jako ``check_api_key`` (sekce s ``api_keys``) a vrátí hashe."""
37
+ items: list[LegacyItem] = []
38
+ for agenda, section in config.items():
39
+ if not isinstance(section, Mapping) or CONFIG_API_KEYS not in section:
40
+ continue
41
+ for entry in section[CONFIG_API_KEYS] or []:
42
+ if not isinstance(entry, Mapping):
43
+ continue
44
+ for raw, user in entry.items():
45
+ items.append(LegacyItem(hash_key(str(raw)), str(agenda), str(user)))
46
+ return items
47
+
48
+
49
+ class LegacyYamlStore:
50
+ """KeyStore nad starým YAML. Klíče z YAML mají oprávnění ``*`` (staré klíče scopes neznaly)."""
51
+
52
+ def __init__(self, config: Mapping[str, Any]) -> None:
53
+ self._rows: dict[str, KeyRecord] = {}
54
+ for item in hash_legacy_config(config):
55
+ self._rows.setdefault(
56
+ item.key_hash,
57
+ KeyRecord(
58
+ key_hash=item.key_hash,
59
+ public_id=item.key_hash[:8],
60
+ client=item.client,
61
+ scopes=frozenset({ALL_SCOPES}),
62
+ created_at=_EPOCH,
63
+ agenda=item.agenda,
64
+ legacy=True,
65
+ ),
66
+ )
67
+
68
+ @classmethod
69
+ def from_file(cls, path: str | Path) -> LegacyYamlStore:
70
+ with open(path, encoding="utf-8") as handle:
71
+ return cls(yaml.safe_load(handle) or {})
72
+
73
+ async def get(self, key_hash: str) -> KeyRecord | None:
74
+ return self._rows.get(key_hash)
75
+
76
+ async def list_by_client(self, client: str) -> list[KeyRecord]:
77
+ return [r for r in self._rows.values() if r.client == client]
78
+
79
+ async def list_all(self) -> list[KeyRecord]:
80
+ return sorted(self._rows.values(), key=lambda r: (r.agenda or "", r.client))
81
+
82
+ async def touch(self, key_hash: str, when: datetime) -> None:
83
+ """Poslední použití se do souboru nezapisuje; je to záměrný no-op, ne chyba."""
84
+ return None
85
+
86
+ async def add(self, record: KeyRecord) -> None:
87
+ raise ReadOnlyStoreError("Starý YAML je jen pro čtení; použijte databázové úložiště.")
88
+
89
+ async def set_expires(self, key_hash: str, expires_at: datetime | None) -> None:
90
+ raise ReadOnlyStoreError("Starý YAML je jen pro čtení.")
91
+
92
+ async def set_revoked(self, key_hash: str, revoked_at: datetime) -> None:
93
+ raise ReadOnlyStoreError("Starý YAML je jen pro čtení.")
@@ -0,0 +1,78 @@
1
+ """Datové modely. Vše neměnné: identita požadavku se předává hodnotou, nikdy sdíleným stavem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ from sysnet_apikeys.errors import AuthorizationError
10
+
11
+ #: Oprávnění „vše“. Dostávají ho klíče převzaté ze starého YAML, které scopes nemají.
12
+ ALL_SCOPES = "*"
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class KeyRecord:
17
+ """Jeden klíč. Otevřený klíč se neukládá nikdy, jen jeho SHA-256."""
18
+
19
+ key_hash: str
20
+ public_id: str
21
+ client: str
22
+ scopes: frozenset[str]
23
+ created_at: datetime
24
+ agenda: str | None = None
25
+ expires_at: datetime | None = None
26
+ revoked_at: datetime | None = None
27
+ last_used_at: datetime | None = None
28
+ created_by: str | None = None
29
+ legacy: bool = False
30
+
31
+ def status(self, now: datetime) -> str:
32
+ if self.revoked_at is not None:
33
+ return "revoked"
34
+ if self.expires_at is not None and self.expires_at <= now:
35
+ return "expired"
36
+ return "active"
37
+
38
+ def is_active(self, now: datetime) -> bool:
39
+ return self.status(now) == "active"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class Principal:
44
+ """Kdo volá. Vzniká při každém ověření znovu a nikde se nesdílí."""
45
+
46
+ client: str
47
+ scopes: frozenset[str]
48
+ public_id: str
49
+ agenda: str | None = None
50
+ legacy: bool = False
51
+
52
+ def has_scopes(self, required: Iterable[str]) -> bool:
53
+ needed = frozenset(required)
54
+ return ALL_SCOPES in self.scopes or needed <= self.scopes
55
+
56
+ def require_scopes(self, *required: str) -> None:
57
+ needed = frozenset(required)
58
+ if not self.has_scopes(needed):
59
+ raise AuthorizationError(needed - self.scopes)
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class IssuedKey:
64
+ """Výsledek vydání. ``key`` se vrací jednou a knihovna ho už nikde neuchová."""
65
+
66
+ record: KeyRecord
67
+ key: str
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class AuditEvent:
72
+ """Událost pro auditní hook. Nikdy neobsahuje klíč ani jeho hash."""
73
+
74
+ action: str
75
+ client: str
76
+ actor: str | None
77
+ public_id: str | None = None
78
+ detail: tuple[tuple[str, str], ...] = ()