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.
- sysnet_apikeys/__init__.py +33 -0
- sysnet_apikeys/admin.py +140 -0
- sysnet_apikeys/cli.py +213 -0
- sysnet_apikeys/errors.py +29 -0
- sysnet_apikeys/fastapi.py +71 -0
- sysnet_apikeys/legacy.py +93 -0
- sysnet_apikeys/models.py +78 -0
- sysnet_apikeys/mongo.py +90 -0
- sysnet_apikeys/py.typed +0 -0
- sysnet_apikeys/security.py +38 -0
- sysnet_apikeys/service.py +200 -0
- sysnet_apikeys/sql.py +127 -0
- sysnet_apikeys/store.py +68 -0
- sysnet_apikeys-0.2.0.dist-info/METADATA +138 -0
- sysnet_apikeys-0.2.0.dist-info/RECORD +19 -0
- sysnet_apikeys-0.2.0.dist-info/WHEEL +5 -0
- sysnet_apikeys-0.2.0.dist-info/entry_points.txt +2 -0
- sysnet_apikeys-0.2.0.dist-info/licenses/LICENSE +661 -0
- sysnet_apikeys-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
sysnet_apikeys/admin.py
ADDED
|
@@ -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()
|
sysnet_apikeys/errors.py
ADDED
|
@@ -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)
|
sysnet_apikeys/legacy.py
ADDED
|
@@ -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í.")
|
sysnet_apikeys/models.py
ADDED
|
@@ -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], ...] = ()
|