cpkit 0.1.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.
- cpkit/README.md +82 -0
- cpkit/__init__.py +42 -0
- cpkit/admin.py +53 -0
- cpkit/app.py +130 -0
- cpkit/audit/README.md +33 -0
- cpkit/audit/__init__.py +44 -0
- cpkit/audit/events_service.py +36 -0
- cpkit/audit/recorder.py +240 -0
- cpkit/audit/repository.py +64 -0
- cpkit/audit/router.py +46 -0
- cpkit/audit/service.py +34 -0
- cpkit/audit/types.py +38 -0
- cpkit/auth/README.md +38 -0
- cpkit/auth/__init__.py +123 -0
- cpkit/auth/api_key_router.py +52 -0
- cpkit/auth/api_key_service.py +143 -0
- cpkit/auth/api_keys.py +143 -0
- cpkit/auth/bundle.py +113 -0
- cpkit/auth/claims.py +37 -0
- cpkit/auth/config.py +162 -0
- cpkit/auth/dependencies.py +155 -0
- cpkit/auth/oidc.py +703 -0
- cpkit/auth/redirects.py +12 -0
- cpkit/auth/repositories.py +181 -0
- cpkit/auth/router.py +214 -0
- cpkit/auth/secrets.py +78 -0
- cpkit/auth/types.py +49 -0
- cpkit/bundle.py +252 -0
- cpkit/cli/README.md +21 -0
- cpkit/cli/__init__.py +14 -0
- cpkit/cli/__main__.py +5 -0
- cpkit/cli/base.py +200 -0
- cpkit/cli/schema.py +30 -0
- cpkit/cli/server.py +22 -0
- cpkit/config/README.md +13 -0
- cpkit/config/__init__.py +9 -0
- cpkit/config/env.py +31 -0
- cpkit/db/README.md +24 -0
- cpkit/db/__init__.py +23 -0
- cpkit/db/postgres.py +252 -0
- cpkit/dependencies.py +73 -0
- cpkit/errors/README.md +22 -0
- cpkit/errors/__init__.py +35 -0
- cpkit/errors/http.py +45 -0
- cpkit/errors/repository.py +32 -0
- cpkit/errors/service.py +74 -0
- cpkit/jobs/README.md +29 -0
- cpkit/jobs/__init__.py +49 -0
- cpkit/jobs/maintenance.py +15 -0
- cpkit/jobs/repository.py +306 -0
- cpkit/jobs/router.py +105 -0
- cpkit/jobs/service.py +138 -0
- cpkit/jobs/types.py +67 -0
- cpkit/jobs/worker.py +200 -0
- cpkit/logging/README.md +19 -0
- cpkit/logging/__init__.py +13 -0
- cpkit/logging/context.py +29 -0
- cpkit/logging/middleware.py +55 -0
- cpkit/logging/setup.py +81 -0
- cpkit/playbooks/README.md +25 -0
- cpkit/playbooks/__init__.py +40 -0
- cpkit/playbooks/ansible.py +359 -0
- cpkit/playbooks/repository.py +95 -0
- cpkit/playbooks/router.py +95 -0
- cpkit/playbooks/service.py +232 -0
- cpkit/playbooks/types.py +43 -0
- cpkit/repository.py +63 -0
- cpkit/resources/__init__.py +17 -0
- cpkit/resources/ddl.sql +150 -0
- cpkit/settings/README.md +22 -0
- cpkit/settings/__init__.py +18 -0
- cpkit/settings/keys.py +30 -0
- cpkit/settings/repository.py +108 -0
- cpkit/settings/router.py +62 -0
- cpkit/settings/service.py +105 -0
- cpkit/settings/types.py +25 -0
- cpkit/time.py +4 -0
- cpkit/webapp/README.md +71 -0
- cpkit/webapp/__init__.py +12 -0
- cpkit/webapp/index.html +970 -0
- cpkit/webapp/script.js +1690 -0
- cpkit/webapp/style.css +1561 -0
- cpkit-0.1.0.dist-info/METADATA +105 -0
- cpkit-0.1.0.dist-info/RECORD +90 -0
- cpkit-0.1.0.dist-info/WHEEL +4 -0
- cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- resources/README.md +16 -0
- resources/repository_maintenance_guide.md +90 -0
- resources/webapp_extension_guide.md +706 -0
- resources/webapp_extension_template.js +130 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Repository helpers for framework-owned audit tables."""
|
|
2
|
+
|
|
3
|
+
from cpkit.db import execute_stmt, fetch_all, fetch_scalar
|
|
4
|
+
|
|
5
|
+
from .types import AuditLogRecord
|
|
6
|
+
|
|
7
|
+
EVENT_LOG_TABLE = "cpkit.event_log"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditEventsRepositoryMixin:
|
|
11
|
+
"""Repository mixin for framework audit event reads and writes."""
|
|
12
|
+
|
|
13
|
+
def list_events(
|
|
14
|
+
self,
|
|
15
|
+
limit: int,
|
|
16
|
+
offset: int,
|
|
17
|
+
groups: list[str] | None = None,
|
|
18
|
+
is_admin: bool = False,
|
|
19
|
+
) -> list[AuditLogRecord]:
|
|
20
|
+
if is_admin:
|
|
21
|
+
return fetch_all(
|
|
22
|
+
f"""
|
|
23
|
+
SELECT ts, user_id, action, job_id, details, request_id::TEXT
|
|
24
|
+
FROM {EVENT_LOG_TABLE}
|
|
25
|
+
ORDER BY ts DESC
|
|
26
|
+
LIMIT %s
|
|
27
|
+
OFFSET %s
|
|
28
|
+
""",
|
|
29
|
+
(limit, offset),
|
|
30
|
+
AuditLogRecord,
|
|
31
|
+
operation="audit.list_events",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
def get_event_count(self) -> int:
|
|
37
|
+
return fetch_scalar(
|
|
38
|
+
f"""
|
|
39
|
+
SELECT count(*) AS id
|
|
40
|
+
FROM {EVENT_LOG_TABLE} AS OF SYSTEM TIME follower_read_timestamp()
|
|
41
|
+
""",
|
|
42
|
+
(),
|
|
43
|
+
operation="audit.get_event_count",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def log_event(self, log_msg: AuditLogRecord):
|
|
47
|
+
execute_stmt(
|
|
48
|
+
f"""
|
|
49
|
+
INSERT INTO {EVENT_LOG_TABLE}
|
|
50
|
+
(ts, user_id, action, job_id, details, request_id)
|
|
51
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
52
|
+
""",
|
|
53
|
+
(
|
|
54
|
+
log_msg.ts,
|
|
55
|
+
log_msg.user_id,
|
|
56
|
+
log_msg.action,
|
|
57
|
+
log_msg.job_id,
|
|
58
|
+
log_msg.details,
|
|
59
|
+
log_msg.request_id,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["AuditEventsRepositoryMixin", "EVENT_LOG_TABLE"]
|
cpkit/audit/router.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Audit event HTTP routes for cpkit FastAPI apps."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Query
|
|
7
|
+
|
|
8
|
+
from .types import AuditEventCountResponse, AuditLogRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_events_router(
|
|
12
|
+
*,
|
|
13
|
+
get_service: Callable[..., Any],
|
|
14
|
+
get_access_scope: Callable[[dict], tuple[list[str], bool]],
|
|
15
|
+
require_readonly: Callable[..., Any],
|
|
16
|
+
handle_service_error: Callable[[Exception], None],
|
|
17
|
+
service_error_type: type[Exception] = Exception,
|
|
18
|
+
) -> APIRouter:
|
|
19
|
+
"""Create the framework audit event read routes."""
|
|
20
|
+
router = APIRouter(prefix="/events", tags=["cpkit"])
|
|
21
|
+
|
|
22
|
+
@router.get("/")
|
|
23
|
+
async def list_events(
|
|
24
|
+
limit: int = Query(default=20, ge=1, le=200),
|
|
25
|
+
offset: int = Query(default=0, ge=0),
|
|
26
|
+
claims: dict = Depends(require_readonly),
|
|
27
|
+
service=Depends(get_service),
|
|
28
|
+
) -> list[AuditLogRecord]:
|
|
29
|
+
groups, is_admin = get_access_scope(claims)
|
|
30
|
+
try:
|
|
31
|
+
return service.list_visible_events(limit, offset, groups, is_admin)
|
|
32
|
+
except service_error_type as err:
|
|
33
|
+
handle_service_error(err)
|
|
34
|
+
|
|
35
|
+
@router.get("/count", response_model=AuditEventCountResponse)
|
|
36
|
+
async def get_event_count(
|
|
37
|
+
service=Depends(get_service),
|
|
38
|
+
) -> AuditEventCountResponse:
|
|
39
|
+
try:
|
|
40
|
+
total = service.get_event_total()
|
|
41
|
+
except service_error_type as err:
|
|
42
|
+
handle_service_error(err)
|
|
43
|
+
|
|
44
|
+
return AuditEventCountResponse(total=total)
|
|
45
|
+
|
|
46
|
+
return router
|
cpkit/audit/service.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Generic audit emission service."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .types import AuditOutcome, AuditRecordCreate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuditService:
|
|
10
|
+
def __init__(self, repo):
|
|
11
|
+
self.repo = repo
|
|
12
|
+
|
|
13
|
+
async def emit(
|
|
14
|
+
self,
|
|
15
|
+
event_type: str | StrEnum,
|
|
16
|
+
*,
|
|
17
|
+
actor_id: str | None = None,
|
|
18
|
+
actor_name: str | None = None,
|
|
19
|
+
resource_type: str | None = None,
|
|
20
|
+
resource_id: str | None = None,
|
|
21
|
+
outcome: AuditOutcome = AuditOutcome.REQUESTED,
|
|
22
|
+
metadata: dict[str, Any] | None = None,
|
|
23
|
+
):
|
|
24
|
+
record = AuditRecordCreate(
|
|
25
|
+
event_type=str(event_type),
|
|
26
|
+
actor_id=actor_id,
|
|
27
|
+
actor_name=actor_name,
|
|
28
|
+
resource_type=resource_type,
|
|
29
|
+
resource_id=resource_id,
|
|
30
|
+
outcome=outcome,
|
|
31
|
+
metadata=metadata or {},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return await self.repo.insert(record)
|
cpkit/audit/types.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Generic audit data types."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditOutcome(StrEnum):
|
|
11
|
+
REQUESTED = "requested"
|
|
12
|
+
STARTED = "started"
|
|
13
|
+
SUCCEEDED = "succeeded"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
DENIED = "denied"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuditRecordCreate(BaseModel):
|
|
19
|
+
event_type: str
|
|
20
|
+
actor_id: str | None = None
|
|
21
|
+
actor_name: str | None = None
|
|
22
|
+
resource_type: str | None = None
|
|
23
|
+
resource_id: str | None = None
|
|
24
|
+
outcome: AuditOutcome = AuditOutcome.REQUESTED
|
|
25
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuditLogRecord(BaseModel):
|
|
29
|
+
ts: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
30
|
+
user_id: str
|
|
31
|
+
action: str
|
|
32
|
+
job_id: int | None = None
|
|
33
|
+
details: dict[str, Any] | None = None
|
|
34
|
+
request_id: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuditEventCountResponse(BaseModel):
|
|
38
|
+
total: int
|
cpkit/auth/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Auth
|
|
2
|
+
|
|
3
|
+
The auth package owns cpkit's authentication and authorization features:
|
|
4
|
+
OIDC browser login, signed API keys, role/group mapping, session storage, and
|
|
5
|
+
FastAPI dependencies such as `require_admin`.
|
|
6
|
+
|
|
7
|
+
Auth is bundled before most other capabilities because routes and services rely
|
|
8
|
+
on it for access checks and audit actor identity.
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
- `bundle.py`: Creates the auth bundle: OIDC manager, dependencies, auth router,
|
|
13
|
+
and auth audit hooks.
|
|
14
|
+
- `oidc.py`: OIDC login, callback, session validation, logout, and API key
|
|
15
|
+
request validation mechanics.
|
|
16
|
+
- `dependencies.py`: FastAPI dependencies for authenticated, readonly, user, and
|
|
17
|
+
admin access.
|
|
18
|
+
- `router.py`: OIDC/session routes.
|
|
19
|
+
- `api_key_router.py`: Admin API key routes.
|
|
20
|
+
- `api_key_service.py`: API key creation/deletion rules.
|
|
21
|
+
- `api_keys.py`: API key generation and signing helpers.
|
|
22
|
+
- `repositories.py`: Repository mixins for API keys, OIDC sessions, and
|
|
23
|
+
role-to-group mappings.
|
|
24
|
+
- `claims.py`: Helpers for interpreting identity/provider claims.
|
|
25
|
+
- `config.py`: OIDC and auth-related configuration access.
|
|
26
|
+
- `redirects.py`: Login redirect helpers.
|
|
27
|
+
- `secrets.py`: Secret encryption/decryption helpers.
|
|
28
|
+
- `types.py`: Auth models and records.
|
|
29
|
+
|
|
30
|
+
## Runtime Flow
|
|
31
|
+
|
|
32
|
+
1. `create_auth_bundle()` builds the OIDC manager and dependency set.
|
|
33
|
+
2. Routers use dependencies like `require_admin` to enforce access.
|
|
34
|
+
3. The OIDC manager persists sessions through repository mixins.
|
|
35
|
+
4. API key requests are verified using stored encrypted secrets and request
|
|
36
|
+
signatures.
|
|
37
|
+
5. Login/logout and API key mutations emit audit events.
|
|
38
|
+
|
cpkit/auth/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Reusable authentication helpers."""
|
|
2
|
+
|
|
3
|
+
from .api_key_router import create_api_keys_router
|
|
4
|
+
from .api_key_service import ApiKeysService
|
|
5
|
+
from .api_keys import (
|
|
6
|
+
APIKeyAuthenticationError,
|
|
7
|
+
APIKeyAuthenticator,
|
|
8
|
+
APIKeyRepository,
|
|
9
|
+
api_key_signature,
|
|
10
|
+
build_api_key_signature_payload,
|
|
11
|
+
parse_api_key_timestamp,
|
|
12
|
+
request_target_bytes,
|
|
13
|
+
)
|
|
14
|
+
from .bundle import (
|
|
15
|
+
DEFAULT_ADMIN_ROLES,
|
|
16
|
+
DEFAULT_OIDC_SESSION_COOKIE_NAME,
|
|
17
|
+
DEFAULT_READONLY_ROLES,
|
|
18
|
+
DEFAULT_USER_ROLES,
|
|
19
|
+
AuthBundle,
|
|
20
|
+
create_auth_bundle,
|
|
21
|
+
)
|
|
22
|
+
from .claims import claim_groups, claims_groups, jsonable_role_groups
|
|
23
|
+
from .config import OIDCConfig
|
|
24
|
+
from .dependencies import (
|
|
25
|
+
ACCESS_KEY_HEADER_NAME,
|
|
26
|
+
SIGNATURE_HEADER_NAME,
|
|
27
|
+
TIMESTAMP_HEADER_NAME,
|
|
28
|
+
AuthDependencies,
|
|
29
|
+
access_key_scheme,
|
|
30
|
+
create_auth_dependencies,
|
|
31
|
+
signature_scheme,
|
|
32
|
+
timestamp_scheme,
|
|
33
|
+
)
|
|
34
|
+
from .oidc import (
|
|
35
|
+
OIDCAuthenticationError,
|
|
36
|
+
OIDCManager,
|
|
37
|
+
OIDCProviderClient,
|
|
38
|
+
OIDCSessionManager,
|
|
39
|
+
OIDCSessionRepository,
|
|
40
|
+
)
|
|
41
|
+
from .redirects import safe_next_path
|
|
42
|
+
from .repositories import (
|
|
43
|
+
ROLE_GROUP_MAPPINGS_TABLE,
|
|
44
|
+
APIKeysRepositoryMixin,
|
|
45
|
+
OIDCSessionsRepositoryMixin,
|
|
46
|
+
RoleGroupMappingsRepositoryMixin,
|
|
47
|
+
)
|
|
48
|
+
from .router import (
|
|
49
|
+
OIDC_NEXT_COOKIE_NAME,
|
|
50
|
+
OIDC_NONCE_COOKIE_NAME,
|
|
51
|
+
OIDC_STATE_COOKIE_NAME,
|
|
52
|
+
create_oidc_router,
|
|
53
|
+
)
|
|
54
|
+
from .secrets import (
|
|
55
|
+
ENCRYPTED_SECRET_VERSION,
|
|
56
|
+
decrypt_secret,
|
|
57
|
+
encrypt_secret,
|
|
58
|
+
validate_secret_crypto_config,
|
|
59
|
+
)
|
|
60
|
+
from .types import (
|
|
61
|
+
ApiKeyCreateRequest,
|
|
62
|
+
ApiKeyCreateRequestInDB,
|
|
63
|
+
ApiKeyCreateResponse,
|
|
64
|
+
ApiKeyRecord,
|
|
65
|
+
ApiKeySummary,
|
|
66
|
+
OIDCSessionRecord,
|
|
67
|
+
RoleGroupMap,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"ENCRYPTED_SECRET_VERSION",
|
|
72
|
+
"OIDCAuthenticationError",
|
|
73
|
+
"OIDCManager",
|
|
74
|
+
"OIDC_NEXT_COOKIE_NAME",
|
|
75
|
+
"OIDC_NONCE_COOKIE_NAME",
|
|
76
|
+
"OIDCProviderClient",
|
|
77
|
+
"OIDCSessionManager",
|
|
78
|
+
"OIDCSessionRepository",
|
|
79
|
+
"OIDC_STATE_COOKIE_NAME",
|
|
80
|
+
"APIKeyAuthenticationError",
|
|
81
|
+
"APIKeyAuthenticator",
|
|
82
|
+
"APIKeyRepository",
|
|
83
|
+
"APIKeysRepositoryMixin",
|
|
84
|
+
"AuthBundle",
|
|
85
|
+
"DEFAULT_ADMIN_ROLES",
|
|
86
|
+
"DEFAULT_OIDC_SESSION_COOKIE_NAME",
|
|
87
|
+
"DEFAULT_READONLY_ROLES",
|
|
88
|
+
"DEFAULT_USER_ROLES",
|
|
89
|
+
"ROLE_GROUP_MAPPINGS_TABLE",
|
|
90
|
+
"ApiKeyCreateRequest",
|
|
91
|
+
"ApiKeyCreateRequestInDB",
|
|
92
|
+
"ApiKeyCreateResponse",
|
|
93
|
+
"ApiKeyRecord",
|
|
94
|
+
"ApiKeySummary",
|
|
95
|
+
"ApiKeysService",
|
|
96
|
+
"OIDCSessionsRepositoryMixin",
|
|
97
|
+
"OIDCSessionRecord",
|
|
98
|
+
"ACCESS_KEY_HEADER_NAME",
|
|
99
|
+
"AuthDependencies",
|
|
100
|
+
"OIDCConfig",
|
|
101
|
+
"RoleGroupMap",
|
|
102
|
+
"RoleGroupMappingsRepositoryMixin",
|
|
103
|
+
"SIGNATURE_HEADER_NAME",
|
|
104
|
+
"TIMESTAMP_HEADER_NAME",
|
|
105
|
+
"access_key_scheme",
|
|
106
|
+
"api_key_signature",
|
|
107
|
+
"build_api_key_signature_payload",
|
|
108
|
+
"claim_groups",
|
|
109
|
+
"claims_groups",
|
|
110
|
+
"create_oidc_router",
|
|
111
|
+
"create_api_keys_router",
|
|
112
|
+
"create_auth_dependencies",
|
|
113
|
+
"create_auth_bundle",
|
|
114
|
+
"decrypt_secret",
|
|
115
|
+
"encrypt_secret",
|
|
116
|
+
"jsonable_role_groups",
|
|
117
|
+
"parse_api_key_timestamp",
|
|
118
|
+
"request_target_bytes",
|
|
119
|
+
"safe_next_path",
|
|
120
|
+
"signature_scheme",
|
|
121
|
+
"timestamp_scheme",
|
|
122
|
+
"validate_secret_crypto_config",
|
|
123
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""FastAPI routes for framework API-key management."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
|
|
8
|
+
from .types import ApiKeyCreateRequest, ApiKeyCreateResponse, ApiKeySummary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_api_keys_router(
|
|
12
|
+
*,
|
|
13
|
+
get_service: Callable[..., Any],
|
|
14
|
+
get_audit_actor: Callable[..., Any],
|
|
15
|
+
handle_service_error: Callable[[Exception], None],
|
|
16
|
+
service_error_type: type[Exception] = Exception,
|
|
17
|
+
) -> APIRouter:
|
|
18
|
+
router = APIRouter(prefix="/api_keys", tags=["cpkit"])
|
|
19
|
+
|
|
20
|
+
@router.get("/")
|
|
21
|
+
async def list_api_keys(
|
|
22
|
+
access_key: str | None = None,
|
|
23
|
+
service=Depends(get_service),
|
|
24
|
+
) -> list[ApiKeySummary]:
|
|
25
|
+
try:
|
|
26
|
+
return service.list_api_keys(access_key)
|
|
27
|
+
except service_error_type as err:
|
|
28
|
+
handle_service_error(err)
|
|
29
|
+
|
|
30
|
+
@router.post("/", response_model=ApiKeyCreateResponse)
|
|
31
|
+
async def create_api_key(
|
|
32
|
+
request: ApiKeyCreateRequest,
|
|
33
|
+
actor_id: str = Depends(get_audit_actor),
|
|
34
|
+
service=Depends(get_service),
|
|
35
|
+
) -> ApiKeyCreateResponse:
|
|
36
|
+
try:
|
|
37
|
+
return service.create_api_key(actor_id, request)
|
|
38
|
+
except service_error_type as err:
|
|
39
|
+
handle_service_error(err)
|
|
40
|
+
|
|
41
|
+
@router.delete("/{access_key}")
|
|
42
|
+
async def delete_api_key(
|
|
43
|
+
access_key: str,
|
|
44
|
+
actor_id: str = Depends(get_audit_actor),
|
|
45
|
+
service=Depends(get_service),
|
|
46
|
+
) -> None:
|
|
47
|
+
try:
|
|
48
|
+
service.delete_api_key(actor_id, access_key)
|
|
49
|
+
except service_error_type as err:
|
|
50
|
+
handle_service_error(err)
|
|
51
|
+
|
|
52
|
+
return router
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Service helpers for framework API-key management."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cpkit.errors import (
|
|
9
|
+
RepositoryError,
|
|
10
|
+
ServiceNotFoundError,
|
|
11
|
+
ServiceValidationError,
|
|
12
|
+
from_repository_error,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .secrets import encrypt_secret
|
|
16
|
+
from .types import (
|
|
17
|
+
ApiKeyCreateRequest,
|
|
18
|
+
ApiKeyCreateRequestInDB,
|
|
19
|
+
ApiKeyCreateResponse,
|
|
20
|
+
ApiKeySummary,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
APIKeyAuditHook = Callable[[Any, str, str, dict[str, Any]], None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiKeysService:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
repo,
|
|
30
|
+
*,
|
|
31
|
+
created_hook: APIKeyAuditHook | None = None,
|
|
32
|
+
deleted_hook: APIKeyAuditHook | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.repo = repo
|
|
35
|
+
self.created_hook = created_hook
|
|
36
|
+
self.deleted_hook = deleted_hook
|
|
37
|
+
|
|
38
|
+
def list_api_keys(self, access_key: str | None = None) -> list[ApiKeySummary]:
|
|
39
|
+
try:
|
|
40
|
+
return self.repo.list_api_keys(access_key)
|
|
41
|
+
except RepositoryError as err:
|
|
42
|
+
raise from_repository_error(
|
|
43
|
+
err,
|
|
44
|
+
unavailable_message="API keys are temporarily unavailable.",
|
|
45
|
+
fallback_message="Unable to load API keys.",
|
|
46
|
+
) from err
|
|
47
|
+
|
|
48
|
+
def create_api_key(
|
|
49
|
+
self,
|
|
50
|
+
actor_id: str,
|
|
51
|
+
request: ApiKeyCreateRequest,
|
|
52
|
+
) -> ApiKeyCreateResponse:
|
|
53
|
+
valid_until = self._normalize_valid_until(request.valid_until)
|
|
54
|
+
|
|
55
|
+
if valid_until <= datetime.now(timezone.utc):
|
|
56
|
+
raise ServiceValidationError("valid_until must be in the future.")
|
|
57
|
+
|
|
58
|
+
secret_access_key = secrets.token_urlsafe(32)
|
|
59
|
+
access_key = "cp-" + secrets.token_urlsafe(16)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
created = self.repo.create_api_key(
|
|
63
|
+
ApiKeyCreateRequestInDB(
|
|
64
|
+
access_key=access_key,
|
|
65
|
+
valid_until=valid_until,
|
|
66
|
+
roles=request.roles,
|
|
67
|
+
),
|
|
68
|
+
owner=actor_id,
|
|
69
|
+
encrypted_secret_access_key=encrypt_secret(secret_access_key),
|
|
70
|
+
)
|
|
71
|
+
except RepositoryError as err:
|
|
72
|
+
raise from_repository_error(
|
|
73
|
+
err,
|
|
74
|
+
unavailable_message="API keys could not be created right now.",
|
|
75
|
+
conflict_message="That API key already exists.",
|
|
76
|
+
validation_message="The API key request is invalid.",
|
|
77
|
+
fallback_message="Unable to create API key.",
|
|
78
|
+
) from err
|
|
79
|
+
|
|
80
|
+
if self.created_hook is not None:
|
|
81
|
+
self.created_hook(
|
|
82
|
+
self.repo,
|
|
83
|
+
actor_id,
|
|
84
|
+
"API_KEY_CREATED",
|
|
85
|
+
{
|
|
86
|
+
"access_key": created.access_key,
|
|
87
|
+
"valid_until": created.valid_until.isoformat(),
|
|
88
|
+
"roles": [_role_value(role) for role in created.roles or []],
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return ApiKeyCreateResponse(
|
|
93
|
+
access_key=created.access_key,
|
|
94
|
+
owner=created.owner,
|
|
95
|
+
valid_until=created.valid_until,
|
|
96
|
+
roles=created.roles,
|
|
97
|
+
secret_access_key=secret_access_key,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def delete_api_key(self, actor_id: str, access_key: str) -> None:
|
|
101
|
+
try:
|
|
102
|
+
existing_key = self.repo.get_api_key(access_key)
|
|
103
|
+
except RepositoryError as err:
|
|
104
|
+
raise from_repository_error(
|
|
105
|
+
err,
|
|
106
|
+
unavailable_message="API keys could not be updated right now.",
|
|
107
|
+
fallback_message=f"Unable to load API key '{access_key}'.",
|
|
108
|
+
) from err
|
|
109
|
+
|
|
110
|
+
if existing_key is None:
|
|
111
|
+
raise ServiceNotFoundError("API key not found.")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
self.repo.delete_api_key(access_key)
|
|
115
|
+
except RepositoryError as err:
|
|
116
|
+
raise from_repository_error(
|
|
117
|
+
err,
|
|
118
|
+
unavailable_message="API keys could not be updated right now.",
|
|
119
|
+
fallback_message=f"Unable to delete API key '{access_key}'.",
|
|
120
|
+
) from err
|
|
121
|
+
|
|
122
|
+
if self.deleted_hook is not None:
|
|
123
|
+
self.deleted_hook(
|
|
124
|
+
self.repo,
|
|
125
|
+
actor_id,
|
|
126
|
+
"API_KEY_DELETED",
|
|
127
|
+
{
|
|
128
|
+
"access_key": existing_key.access_key,
|
|
129
|
+
"owner": existing_key.owner,
|
|
130
|
+
"valid_until": existing_key.valid_until.isoformat(),
|
|
131
|
+
"roles": [_role_value(role) for role in existing_key.roles or []],
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _normalize_valid_until(value: datetime) -> datetime:
|
|
137
|
+
if value.tzinfo is None:
|
|
138
|
+
return value.replace(tzinfo=timezone.utc)
|
|
139
|
+
return value.astimezone(timezone.utc)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _role_value(role: Any) -> str:
|
|
143
|
+
return str(getattr(role, "value", role))
|
cpkit/auth/api_keys.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Reusable API-key request signing helpers."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
from hmac import compare_digest
|
|
6
|
+
from hmac import new as hmac_new
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIKeyAuthenticationError(Exception):
|
|
11
|
+
"""Raised when an API key request cannot be authenticated."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, detail: str) -> None:
|
|
14
|
+
self.detail = detail
|
|
15
|
+
super().__init__(detail)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIKeyRepository(Protocol):
|
|
19
|
+
"""Repository-like object that can fetch API key records."""
|
|
20
|
+
|
|
21
|
+
def get_api_key(self, access_key: str) -> Any | None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_api_key_timestamp(timestamp: str) -> datetime:
|
|
25
|
+
"""Parse either epoch seconds or an ISO-8601 timestamp into UTC."""
|
|
26
|
+
raw_timestamp = timestamp.strip()
|
|
27
|
+
if not raw_timestamp:
|
|
28
|
+
raise ValueError("empty timestamp")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
parsed = datetime.fromtimestamp(float(raw_timestamp), tz=timezone.utc)
|
|
32
|
+
except OSError, OverflowError, ValueError:
|
|
33
|
+
normalized = (
|
|
34
|
+
f"{raw_timestamp[:-1]}+00:00"
|
|
35
|
+
if raw_timestamp.endswith("Z")
|
|
36
|
+
else raw_timestamp
|
|
37
|
+
)
|
|
38
|
+
parsed = datetime.fromisoformat(normalized)
|
|
39
|
+
if parsed.tzinfo is None:
|
|
40
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
41
|
+
else:
|
|
42
|
+
parsed = parsed.astimezone(timezone.utc)
|
|
43
|
+
|
|
44
|
+
return parsed
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def request_target_bytes(request: Any) -> bytes:
|
|
48
|
+
"""Return the exact path and query bytes covered by the request signature."""
|
|
49
|
+
raw_path = request.scope.get("raw_path")
|
|
50
|
+
if isinstance(raw_path, bytes) and raw_path:
|
|
51
|
+
path = raw_path
|
|
52
|
+
else:
|
|
53
|
+
path = request.url.path.encode("utf-8")
|
|
54
|
+
|
|
55
|
+
query_string = request.scope.get("query_string")
|
|
56
|
+
if isinstance(query_string, bytes) and query_string:
|
|
57
|
+
return path + b"?" + query_string
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_api_key_signature_payload(
|
|
62
|
+
request: Any,
|
|
63
|
+
timestamp: str,
|
|
64
|
+
body: bytes,
|
|
65
|
+
) -> bytes:
|
|
66
|
+
"""Build the canonical payload used for HMAC request signing."""
|
|
67
|
+
return b"\n".join(
|
|
68
|
+
[
|
|
69
|
+
request.method.upper().encode("utf-8"),
|
|
70
|
+
request_target_bytes(request),
|
|
71
|
+
timestamp.strip().encode("utf-8"),
|
|
72
|
+
body,
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def api_key_signature(
|
|
78
|
+
secret_key: bytes,
|
|
79
|
+
request: Any,
|
|
80
|
+
timestamp: str,
|
|
81
|
+
body: bytes,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Return the expected HMAC signature for an API-key-authenticated request."""
|
|
84
|
+
return hmac_new(
|
|
85
|
+
secret_key,
|
|
86
|
+
build_api_key_signature_payload(request, timestamp, body),
|
|
87
|
+
sha256,
|
|
88
|
+
).hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class APIKeyAuthenticator:
|
|
92
|
+
"""Authenticate HMAC-signed API key requests."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, *, decrypt_secret) -> None:
|
|
95
|
+
self.decrypt_secret = decrypt_secret
|
|
96
|
+
|
|
97
|
+
async def authenticate_request(
|
|
98
|
+
self,
|
|
99
|
+
request: Any,
|
|
100
|
+
repo: APIKeyRepository,
|
|
101
|
+
*,
|
|
102
|
+
access_key: str,
|
|
103
|
+
signature: str,
|
|
104
|
+
timestamp: str,
|
|
105
|
+
max_age_seconds: int,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
api_key = repo.get_api_key(access_key)
|
|
108
|
+
if api_key is None:
|
|
109
|
+
raise APIKeyAuthenticationError("Invalid API key.")
|
|
110
|
+
|
|
111
|
+
if datetime.now(timezone.utc) >= api_key.valid_until:
|
|
112
|
+
raise APIKeyAuthenticationError("API key is expired.")
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
signed_at = parse_api_key_timestamp(timestamp)
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise APIKeyAuthenticationError("Invalid X-Timestamp header.") from exc
|
|
118
|
+
|
|
119
|
+
age_seconds = abs((datetime.now(timezone.utc) - signed_at).total_seconds())
|
|
120
|
+
if age_seconds > max_age_seconds:
|
|
121
|
+
raise APIKeyAuthenticationError("API request timestamp is expired.")
|
|
122
|
+
|
|
123
|
+
body = await request.body()
|
|
124
|
+
secret_key = self.decrypt_secret(api_key.encrypted_secret_access_key)
|
|
125
|
+
expected_signature = api_key_signature(secret_key, request, timestamp, body)
|
|
126
|
+
|
|
127
|
+
if not compare_digest(expected_signature, signature.strip().lower()):
|
|
128
|
+
raise APIKeyAuthenticationError("Invalid API key signature.")
|
|
129
|
+
|
|
130
|
+
roles = set(api_key.roles or [])
|
|
131
|
+
role_groups = {role: {_role_value(role)} for role in roles}
|
|
132
|
+
return {
|
|
133
|
+
"sub": api_key.owner,
|
|
134
|
+
"access_key": api_key.access_key,
|
|
135
|
+
"groups": [_role_value(role) for role in roles],
|
|
136
|
+
"_groups_claim_name": "groups",
|
|
137
|
+
"_role_groups": role_groups,
|
|
138
|
+
"auth_type": "api_key",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _role_value(role: Any) -> str:
|
|
143
|
+
return str(getattr(role, "value", role))
|