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
cpkit/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# cpkit Code Map
|
|
2
|
+
|
|
3
|
+
cpkit is an application framework for FastAPI apps that want the same
|
|
4
|
+
operational backbone: authentication, audit events, settings, jobs, playbooks,
|
|
5
|
+
database helpers, and a shared webapp shell.
|
|
6
|
+
|
|
7
|
+
The package is organized by capability. The directories look flat at first
|
|
8
|
+
glance, but most capability packages use the same internal pattern:
|
|
9
|
+
|
|
10
|
+
- `types.py`: Pydantic models, enums, and response shapes.
|
|
11
|
+
- `repository.py` or `repositories.py`: database mixins for framework-owned
|
|
12
|
+
tables.
|
|
13
|
+
- `service.py`: business rules and repository error translation.
|
|
14
|
+
- `router.py`: FastAPI routes and dependency wiring.
|
|
15
|
+
- `__init__.py`: public exports for that capability.
|
|
16
|
+
|
|
17
|
+
The main app flow is:
|
|
18
|
+
|
|
19
|
+
1. An app calls `create_cpkit_bundle()` from `bundle.py` to assemble cpkit
|
|
20
|
+
capabilities.
|
|
21
|
+
2. The app calls `create_cpkit_app()` from `app.py` to create the FastAPI app,
|
|
22
|
+
configure the repository, run startup hooks, mount routers, and launch
|
|
23
|
+
background tasks.
|
|
24
|
+
3. `repository.py` provides `CPKitRepo`, a mix of all framework repository
|
|
25
|
+
capabilities. Apps can subclass it and add their own repository methods.
|
|
26
|
+
4. Capability services use `get_repo()` to work with the configured app repo.
|
|
27
|
+
5. The shared webapp in `webapp/` talks to the framework API routes and can be
|
|
28
|
+
extended by app-owned web assets.
|
|
29
|
+
|
|
30
|
+
## Architecture Review
|
|
31
|
+
|
|
32
|
+
The current flat capability layout is reasonable for cpkit's size. It keeps
|
|
33
|
+
imports readable and makes each framework feature easy to find. A deeper layout
|
|
34
|
+
like `cpkit/core`, `cpkit/capabilities`, and `cpkit/integrations` might become
|
|
35
|
+
useful later, but moving there now would mostly create import churn.
|
|
36
|
+
|
|
37
|
+
The part that made the project feel like a black box was not the package shape;
|
|
38
|
+
it was the lack of an orientation layer. These README files are intended to be
|
|
39
|
+
that layer. They explain the module boundaries without changing runtime code.
|
|
40
|
+
|
|
41
|
+
Good rules of thumb:
|
|
42
|
+
|
|
43
|
+
- Start in `bundle.py` when asking "how does cpkit assemble itself?"
|
|
44
|
+
- Start in `app.py` when asking "how does cpkit become a FastAPI app?"
|
|
45
|
+
- Start in `repository.py` when asking "how do framework services reach the DB?"
|
|
46
|
+
- Start in a capability package when asking about one feature area.
|
|
47
|
+
- Start in `webapp/README.md` for frontend extension behavior.
|
|
48
|
+
|
|
49
|
+
## Top-Level Modules
|
|
50
|
+
|
|
51
|
+
- `app.py`: FastAPI app bootstrap, lifespan management, router mounting, static
|
|
52
|
+
webapp mounting, DB initialization, and background task startup.
|
|
53
|
+
- `bundle.py`: The standard cpkit capability bundle. It wires auth, jobs,
|
|
54
|
+
events, admin routes, settings, playbooks, API keys, audit hooks, and the
|
|
55
|
+
queue worker.
|
|
56
|
+
- `repository.py`: The framework repository composition point. `CPKitRepo`
|
|
57
|
+
combines all cpkit repository mixins; `configure_repository()` tells cpkit how
|
|
58
|
+
to create app repo instances.
|
|
59
|
+
- `resources/`: Packaged framework resources such as `ddl.sql`. Use
|
|
60
|
+
`cpkit_ddl_path()` to locate them from installed wheels.
|
|
61
|
+
- `admin.py`: Composes built-in admin routers under the admin API prefix.
|
|
62
|
+
- `dependencies.py`: Global dependency accessors used by routers after a bundle
|
|
63
|
+
has been configured.
|
|
64
|
+
- `time.py`: Shared timestamp formatting constants.
|
|
65
|
+
|
|
66
|
+
## Capability Packages
|
|
67
|
+
|
|
68
|
+
- `audit/`: audit event records, event reads, audit hooks, and the context used
|
|
69
|
+
to attach request/job identifiers.
|
|
70
|
+
- `auth/`: OIDC login/logout, API key auth, role/group mapping, encrypted
|
|
71
|
+
secrets, and auth dependencies.
|
|
72
|
+
- `cli/`: reusable app CLI for schema initialization, schema checks, and serving.
|
|
73
|
+
- `config/`: small environment/config helpers.
|
|
74
|
+
- `db/`: database pool, query helpers, Cockroach/Postgres compatibility, and
|
|
75
|
+
DB error translation.
|
|
76
|
+
- `errors/`: repository/service/http exception layers.
|
|
77
|
+
- `jobs/`: framework job table, queue table, queue worker, job APIs, and job
|
|
78
|
+
rescheduling.
|
|
79
|
+
- `logging/`: request id context, middleware, and logging setup.
|
|
80
|
+
- `playbooks/`: versioned playbook storage and Ansible runner integration.
|
|
81
|
+
- `settings/`: framework settings table and admin settings API.
|
|
82
|
+
- `webapp/`: static HTML/CSS/JS shell and extension contract for app UIs.
|
cpkit/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Reusable control-plane framework primitives."""
|
|
2
|
+
|
|
3
|
+
from .admin import create_cpkit_admin_router
|
|
4
|
+
from .app import create_cpkit_app
|
|
5
|
+
from .bundle import CpkitBundle, create_cpkit_bundle
|
|
6
|
+
from .cli import ApplicationCLI
|
|
7
|
+
from .dependencies import (
|
|
8
|
+
configure_cpkit_dependencies,
|
|
9
|
+
get_access_scope,
|
|
10
|
+
get_audit_actor,
|
|
11
|
+
require_admin,
|
|
12
|
+
require_authenticated,
|
|
13
|
+
require_readonly,
|
|
14
|
+
require_user,
|
|
15
|
+
)
|
|
16
|
+
from .repository import CPKitRepo, configure_repository, get_repo
|
|
17
|
+
from .resources import cpkit_ddl_path, cpkit_resources_directory
|
|
18
|
+
from .time import STRFTIME, TS_FORMAT
|
|
19
|
+
from .webapp import template_webapp_directory
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CPKitRepo",
|
|
23
|
+
"ApplicationCLI",
|
|
24
|
+
"CpkitBundle",
|
|
25
|
+
"STRFTIME",
|
|
26
|
+
"TS_FORMAT",
|
|
27
|
+
"configure_cpkit_dependencies",
|
|
28
|
+
"configure_repository",
|
|
29
|
+
"create_cpkit_bundle",
|
|
30
|
+
"create_cpkit_admin_router",
|
|
31
|
+
"create_cpkit_app",
|
|
32
|
+
"cpkit_ddl_path",
|
|
33
|
+
"cpkit_resources_directory",
|
|
34
|
+
"get_access_scope",
|
|
35
|
+
"get_audit_actor",
|
|
36
|
+
"get_repo",
|
|
37
|
+
"require_admin",
|
|
38
|
+
"require_authenticated",
|
|
39
|
+
"require_readonly",
|
|
40
|
+
"require_user",
|
|
41
|
+
"template_webapp_directory",
|
|
42
|
+
]
|
cpkit/admin.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Admin router assembly for framework-owned capabilities."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter
|
|
7
|
+
|
|
8
|
+
from .auth import create_api_keys_router
|
|
9
|
+
from .playbooks import create_playbooks_router
|
|
10
|
+
from .settings import create_settings_router
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_cpkit_admin_router(
|
|
14
|
+
*,
|
|
15
|
+
get_api_keys_service: Callable[..., Any],
|
|
16
|
+
get_settings_service: Callable[..., Any],
|
|
17
|
+
get_playbooks_service: Callable[..., Any],
|
|
18
|
+
get_audit_actor: Callable[..., Any],
|
|
19
|
+
handle_service_error: Callable[[Exception], None],
|
|
20
|
+
service_error_type: type[Exception] = Exception,
|
|
21
|
+
prefix: str = "/admin",
|
|
22
|
+
dependencies: Sequence[Any] | None = None,
|
|
23
|
+
) -> APIRouter:
|
|
24
|
+
"""Create the standard admin routes provided by cpkit."""
|
|
25
|
+
router = APIRouter(
|
|
26
|
+
prefix=prefix,
|
|
27
|
+
dependencies=list(dependencies or ()),
|
|
28
|
+
)
|
|
29
|
+
router.include_router(
|
|
30
|
+
create_settings_router(
|
|
31
|
+
get_service=get_settings_service,
|
|
32
|
+
get_audit_actor=get_audit_actor,
|
|
33
|
+
handle_service_error=handle_service_error,
|
|
34
|
+
service_error_type=service_error_type,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
router.include_router(
|
|
38
|
+
create_playbooks_router(
|
|
39
|
+
get_service=get_playbooks_service,
|
|
40
|
+
get_audit_actor=get_audit_actor,
|
|
41
|
+
handle_service_error=handle_service_error,
|
|
42
|
+
service_error_type=service_error_type,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
router.include_router(
|
|
46
|
+
create_api_keys_router(
|
|
47
|
+
get_service=get_api_keys_service,
|
|
48
|
+
get_audit_actor=get_audit_actor,
|
|
49
|
+
handle_service_error=handle_service_error,
|
|
50
|
+
service_error_type=service_error_type,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return router
|
cpkit/app.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""FastAPI application bootstrap helpers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
|
|
13
|
+
from cpkit.bundle import CpkitBundle
|
|
14
|
+
from cpkit.db import close_db, initialize_postgres
|
|
15
|
+
from cpkit.logging import configure_logging, request_logging_middleware
|
|
16
|
+
from cpkit.repository import configure_repository
|
|
17
|
+
from cpkit.repository import get_repo as get_configured_repo
|
|
18
|
+
|
|
19
|
+
StartupHook = Callable[[], Any]
|
|
20
|
+
BackgroundTaskFactory = Callable[[], Awaitable[Any]]
|
|
21
|
+
RepoClass = Callable[[Any], Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_cpkit_app(
|
|
25
|
+
*,
|
|
26
|
+
title: str,
|
|
27
|
+
version: str,
|
|
28
|
+
repo_class: RepoClass | None = None,
|
|
29
|
+
get_repo: Callable[[], Any] | None = None,
|
|
30
|
+
db_url: str | None,
|
|
31
|
+
capabilities: Iterable[CpkitBundle] | None = None,
|
|
32
|
+
bundles: Iterable[CpkitBundle] = (),
|
|
33
|
+
routers: Iterable[APIRouter] = (),
|
|
34
|
+
startup_hooks: Iterable[StartupHook] = (),
|
|
35
|
+
background_tasks: Iterable[BackgroundTaskFactory] = (),
|
|
36
|
+
static_directory: str | Path | None = None,
|
|
37
|
+
app_static_directory: str | Path | None = None,
|
|
38
|
+
api_prefix: str = "/api",
|
|
39
|
+
default_journald_identifier: str = "cp",
|
|
40
|
+
) -> FastAPI:
|
|
41
|
+
"""Create a cpkit-managed FastAPI app with an application API subapp."""
|
|
42
|
+
capability_tuple = tuple(capabilities if capabilities is not None else bundles)
|
|
43
|
+
|
|
44
|
+
@asynccontextmanager
|
|
45
|
+
async def lifespan(_app: FastAPI):
|
|
46
|
+
running_tasks: list[asyncio.Task[Any]] = []
|
|
47
|
+
|
|
48
|
+
initialize_postgres(db_url)
|
|
49
|
+
if repo_class is not None:
|
|
50
|
+
configure_repository(repo_class=repo_class)
|
|
51
|
+
elif get_repo is not None:
|
|
52
|
+
configure_repository(repo_factory=get_repo)
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError("repo_class or get_repo is required.")
|
|
55
|
+
|
|
56
|
+
repo = get_configured_repo()
|
|
57
|
+
configure_logging(
|
|
58
|
+
repo,
|
|
59
|
+
force=True,
|
|
60
|
+
default_journald_identifier=default_journald_identifier,
|
|
61
|
+
)
|
|
62
|
+
effective_startup_hooks = (
|
|
63
|
+
*(
|
|
64
|
+
hook
|
|
65
|
+
for capability in capability_tuple
|
|
66
|
+
for hook in capability.startup_hooks
|
|
67
|
+
),
|
|
68
|
+
*startup_hooks,
|
|
69
|
+
)
|
|
70
|
+
for hook in effective_startup_hooks:
|
|
71
|
+
result = hook()
|
|
72
|
+
if inspect.isawaitable(result):
|
|
73
|
+
await result
|
|
74
|
+
|
|
75
|
+
effective_background_tasks = (
|
|
76
|
+
*(
|
|
77
|
+
task
|
|
78
|
+
for capability in capability_tuple
|
|
79
|
+
for task in capability.background_tasks
|
|
80
|
+
),
|
|
81
|
+
*background_tasks,
|
|
82
|
+
)
|
|
83
|
+
running_tasks = [
|
|
84
|
+
asyncio.create_task(task_factory())
|
|
85
|
+
for task_factory in effective_background_tasks
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
yield
|
|
89
|
+
|
|
90
|
+
for task in running_tasks:
|
|
91
|
+
task.cancel()
|
|
92
|
+
for task in running_tasks:
|
|
93
|
+
try:
|
|
94
|
+
await task
|
|
95
|
+
except asyncio.CancelledError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
close_db()
|
|
99
|
+
|
|
100
|
+
app = FastAPI(lifespan=lifespan)
|
|
101
|
+
api = FastAPI(title=title, version=version)
|
|
102
|
+
|
|
103
|
+
effective_routers = (
|
|
104
|
+
*(router for capability in capability_tuple for router in capability.routers),
|
|
105
|
+
*routers,
|
|
106
|
+
)
|
|
107
|
+
for router in effective_routers:
|
|
108
|
+
api.include_router(router)
|
|
109
|
+
|
|
110
|
+
app.mount(api_prefix, api)
|
|
111
|
+
|
|
112
|
+
if app_static_directory is not None:
|
|
113
|
+
app.mount(
|
|
114
|
+
"/app",
|
|
115
|
+
StaticFiles(directory=Path(app_static_directory), html=True),
|
|
116
|
+
name="app_webapp",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if static_directory is not None:
|
|
120
|
+
app.mount(
|
|
121
|
+
"/",
|
|
122
|
+
StaticFiles(directory=Path(static_directory), html=True),
|
|
123
|
+
name="webapp",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@app.middleware("http")
|
|
127
|
+
async def dispatch(request: Request, call_next):
|
|
128
|
+
return await request_logging_middleware(request, call_next)
|
|
129
|
+
|
|
130
|
+
return app
|
cpkit/audit/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Audit
|
|
2
|
+
|
|
3
|
+
The audit package records and exposes framework/application events such as
|
|
4
|
+
logins, settings changes, API key changes, reschedules, and app-defined actions.
|
|
5
|
+
|
|
6
|
+
The important idea is that audit records are app-owned objects built through a
|
|
7
|
+
record factory, then written through the configured repository. cpkit provides a
|
|
8
|
+
default `AuditLogRecord`, but apps can provide their own factory if their audit
|
|
9
|
+
schema differs.
|
|
10
|
+
|
|
11
|
+
## Files
|
|
12
|
+
|
|
13
|
+
- `types.py`: Default audit models, including `AuditLogRecord`.
|
|
14
|
+
- `recorder.py`: The common audit recorder and hooks. This is where request ids
|
|
15
|
+
and job ids are resolved before calling the record factory.
|
|
16
|
+
- `repository.py`: Repository mixin for reading and writing `cpkit.event_log`.
|
|
17
|
+
- `events_service.py`: Service used by the Events page/API to list events.
|
|
18
|
+
- `router.py`: Read-only API routes for audit events.
|
|
19
|
+
- `service.py`: Generic audit emission service for app code that wants a small
|
|
20
|
+
service wrapper.
|
|
21
|
+
|
|
22
|
+
## Runtime Flow
|
|
23
|
+
|
|
24
|
+
1. A service calls `log_event()` or a hook created by `create_audit_event_hook()`.
|
|
25
|
+
2. `AuditRecorder` resolves contextual values, currently `request_id` and
|
|
26
|
+
`job_id`, through provider functions.
|
|
27
|
+
3. The configured record factory receives `actor_id`, `event_type`, `metadata`,
|
|
28
|
+
`request_id`, and `job_id`.
|
|
29
|
+
4. The resulting record is written through the repository's `log_event()` method.
|
|
30
|
+
|
|
31
|
+
Best-effort audit logging should not break the user workflow. If an audit record
|
|
32
|
+
cannot be built or written, cpkit logs the exception and returns `False`.
|
|
33
|
+
|
cpkit/audit/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Generic audit record types and service helpers."""
|
|
2
|
+
|
|
3
|
+
from .events_service import AuditEventsService
|
|
4
|
+
from .recorder import (
|
|
5
|
+
AuditRecorder,
|
|
6
|
+
AuditRecordWriter,
|
|
7
|
+
build_audit_log_record,
|
|
8
|
+
configure_audit_logging,
|
|
9
|
+
create_audit_event_hook,
|
|
10
|
+
job_id_ctx,
|
|
11
|
+
log_event,
|
|
12
|
+
write_audit_record,
|
|
13
|
+
write_audit_record_best_effort,
|
|
14
|
+
)
|
|
15
|
+
from .repository import EVENT_LOG_TABLE, AuditEventsRepositoryMixin
|
|
16
|
+
from .router import create_events_router
|
|
17
|
+
from .service import AuditService
|
|
18
|
+
from .types import (
|
|
19
|
+
AuditEventCountResponse,
|
|
20
|
+
AuditLogRecord,
|
|
21
|
+
AuditOutcome,
|
|
22
|
+
AuditRecordCreate,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AuditEventCountResponse",
|
|
27
|
+
"AuditEventsRepositoryMixin",
|
|
28
|
+
"AuditEventsService",
|
|
29
|
+
"AuditLogRecord",
|
|
30
|
+
"AuditOutcome",
|
|
31
|
+
"AuditRecorder",
|
|
32
|
+
"AuditRecordCreate",
|
|
33
|
+
"AuditRecordWriter",
|
|
34
|
+
"AuditService",
|
|
35
|
+
"EVENT_LOG_TABLE",
|
|
36
|
+
"build_audit_log_record",
|
|
37
|
+
"configure_audit_logging",
|
|
38
|
+
"create_events_router",
|
|
39
|
+
"create_audit_event_hook",
|
|
40
|
+
"job_id_ctx",
|
|
41
|
+
"log_event",
|
|
42
|
+
"write_audit_record",
|
|
43
|
+
"write_audit_record_best_effort",
|
|
44
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Service helpers for framework audit event reads."""
|
|
2
|
+
|
|
3
|
+
from cpkit.errors import RepositoryError, from_repository_error
|
|
4
|
+
|
|
5
|
+
from .types import AuditLogRecord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuditEventsService:
|
|
9
|
+
def __init__(self, repo) -> None:
|
|
10
|
+
self.repo = repo
|
|
11
|
+
|
|
12
|
+
def list_visible_events(
|
|
13
|
+
self,
|
|
14
|
+
limit: int,
|
|
15
|
+
offset: int,
|
|
16
|
+
groups: list[str],
|
|
17
|
+
is_admin: bool,
|
|
18
|
+
) -> list[AuditLogRecord]:
|
|
19
|
+
try:
|
|
20
|
+
return self.repo.list_events(limit, offset, groups, is_admin)
|
|
21
|
+
except RepositoryError as err:
|
|
22
|
+
raise from_repository_error(
|
|
23
|
+
err,
|
|
24
|
+
unavailable_message="Events are temporarily unavailable.",
|
|
25
|
+
fallback_message="Unable to load events.",
|
|
26
|
+
) from err
|
|
27
|
+
|
|
28
|
+
def get_event_total(self) -> int:
|
|
29
|
+
try:
|
|
30
|
+
return self.repo.get_event_count()
|
|
31
|
+
except RepositoryError as err:
|
|
32
|
+
raise from_repository_error(
|
|
33
|
+
err,
|
|
34
|
+
unavailable_message="Event totals are temporarily unavailable.",
|
|
35
|
+
fallback_message="Unable to load the event count.",
|
|
36
|
+
) from err
|
cpkit/audit/recorder.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Audit recording helpers for application-owned audit record models."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from cpkit.logging import request_id_ctx
|
|
10
|
+
|
|
11
|
+
from .types import AuditLogRecord
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
_audit_record_factory: Callable[..., Any] | None = None
|
|
15
|
+
job_id_ctx: ContextVar[int | None] = ContextVar("job_id", default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuditRecordWriter(Protocol):
|
|
19
|
+
"""Repository-like object that can persist an application audit record."""
|
|
20
|
+
|
|
21
|
+
def log_event(self, record: Any) -> Any: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_audit_record(
|
|
25
|
+
writer: Any,
|
|
26
|
+
record: Any,
|
|
27
|
+
*,
|
|
28
|
+
method_name: str = "log_event",
|
|
29
|
+
) -> Any:
|
|
30
|
+
"""Write an application-owned audit record through a repository-like writer."""
|
|
31
|
+
return getattr(writer, method_name)(record)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_audit_record_best_effort(
|
|
35
|
+
writer: Any,
|
|
36
|
+
record: Any,
|
|
37
|
+
*,
|
|
38
|
+
event_type: str | None = None,
|
|
39
|
+
method_name: str = "log_event",
|
|
40
|
+
event_logger: logging.Logger | None = None,
|
|
41
|
+
) -> bool:
|
|
42
|
+
"""Write an audit record without letting audit failures fail the caller."""
|
|
43
|
+
active_logger = event_logger or logger
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
write_audit_record(writer, record, method_name=method_name)
|
|
47
|
+
except Exception:
|
|
48
|
+
active_logger.exception("Failed to write audit event %s", event_type or record)
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AuditRecorder:
|
|
55
|
+
"""Build and persist application-owned audit records with common mechanics."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
writer: AuditRecordWriter,
|
|
60
|
+
record_factory: Callable[..., Any],
|
|
61
|
+
*,
|
|
62
|
+
request_id_provider: Callable[[], str | None] | None = None,
|
|
63
|
+
job_id_provider: Callable[[], int | None] | None = None,
|
|
64
|
+
method_name: str = "log_event",
|
|
65
|
+
event_logger: logging.Logger | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.writer = writer
|
|
68
|
+
self.record_factory = record_factory
|
|
69
|
+
self.request_id_provider = request_id_provider
|
|
70
|
+
self.job_id_provider = job_id_provider
|
|
71
|
+
self.method_name = method_name
|
|
72
|
+
self.logger = event_logger or logger
|
|
73
|
+
|
|
74
|
+
def emit(
|
|
75
|
+
self,
|
|
76
|
+
event_type: str | StrEnum,
|
|
77
|
+
*,
|
|
78
|
+
actor_id: str,
|
|
79
|
+
metadata: dict[str, Any] | None = None,
|
|
80
|
+
request_id: str | None = None,
|
|
81
|
+
job_id: int | None = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Create and write an application-owned audit record."""
|
|
84
|
+
record = self._build_record(
|
|
85
|
+
event_type,
|
|
86
|
+
actor_id=actor_id,
|
|
87
|
+
metadata=metadata,
|
|
88
|
+
request_id=request_id,
|
|
89
|
+
job_id=job_id,
|
|
90
|
+
)
|
|
91
|
+
return write_audit_record(
|
|
92
|
+
self.writer,
|
|
93
|
+
record,
|
|
94
|
+
method_name=self.method_name,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def emit_best_effort(
|
|
98
|
+
self,
|
|
99
|
+
event_type: str | StrEnum,
|
|
100
|
+
*,
|
|
101
|
+
actor_id: str,
|
|
102
|
+
metadata: dict[str, Any] | None = None,
|
|
103
|
+
request_id: str | None = None,
|
|
104
|
+
job_id: int | None = None,
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Create and write an audit record without failing the caller."""
|
|
107
|
+
try:
|
|
108
|
+
record = self._build_record(
|
|
109
|
+
event_type,
|
|
110
|
+
actor_id=actor_id,
|
|
111
|
+
metadata=metadata,
|
|
112
|
+
request_id=request_id,
|
|
113
|
+
job_id=job_id,
|
|
114
|
+
)
|
|
115
|
+
return write_audit_record_best_effort(
|
|
116
|
+
self.writer,
|
|
117
|
+
record,
|
|
118
|
+
event_type=str(event_type),
|
|
119
|
+
method_name=self.method_name,
|
|
120
|
+
event_logger=self.logger,
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
self.logger.exception("Failed to build audit event %s", event_type)
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def _build_record(
|
|
127
|
+
self,
|
|
128
|
+
event_type: str | StrEnum,
|
|
129
|
+
*,
|
|
130
|
+
actor_id: str,
|
|
131
|
+
metadata: dict[str, Any] | None,
|
|
132
|
+
request_id: str | None,
|
|
133
|
+
job_id: int | None,
|
|
134
|
+
) -> Any:
|
|
135
|
+
effective_request_id = request_id
|
|
136
|
+
if effective_request_id is None and self.request_id_provider is not None:
|
|
137
|
+
effective_request_id = self.request_id_provider()
|
|
138
|
+
|
|
139
|
+
effective_job_id = job_id
|
|
140
|
+
if effective_job_id is None and self.job_id_provider is not None:
|
|
141
|
+
effective_job_id = self.job_id_provider()
|
|
142
|
+
|
|
143
|
+
return self.record_factory(
|
|
144
|
+
actor_id=actor_id,
|
|
145
|
+
event_type=str(event_type),
|
|
146
|
+
metadata=metadata,
|
|
147
|
+
request_id=effective_request_id,
|
|
148
|
+
job_id=effective_job_id,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_audit_log_record(
|
|
153
|
+
*,
|
|
154
|
+
actor_id: str,
|
|
155
|
+
event_type: str,
|
|
156
|
+
metadata: dict[str, Any] | None,
|
|
157
|
+
request_id: str | None,
|
|
158
|
+
job_id: int | None,
|
|
159
|
+
default_metadata: dict[str, Any] | None = None,
|
|
160
|
+
) -> AuditLogRecord:
|
|
161
|
+
"""Build the standard cpkit audit log record."""
|
|
162
|
+
effective_metadata = metadata if metadata is not None else default_metadata
|
|
163
|
+
return AuditLogRecord(
|
|
164
|
+
user_id=actor_id,
|
|
165
|
+
action=event_type,
|
|
166
|
+
job_id=job_id,
|
|
167
|
+
details=effective_metadata,
|
|
168
|
+
request_id=_normalize_request_id(request_id),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _normalize_request_id(request_id: str | None) -> str | None:
|
|
173
|
+
return (str(request_id).strip() or None) if request_id else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def configure_audit_logging(record_factory: Callable[..., Any]) -> None:
|
|
177
|
+
"""Configure the default audit record factory for framework logging APIs."""
|
|
178
|
+
global _audit_record_factory
|
|
179
|
+
_audit_record_factory = record_factory
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def create_audit_event_hook(
|
|
183
|
+
record_factory: Callable[..., Any],
|
|
184
|
+
*,
|
|
185
|
+
request_id_provider: Callable[[], str | None] = request_id_ctx.get,
|
|
186
|
+
job_id_provider: Callable[[], int | None] = job_id_ctx.get,
|
|
187
|
+
best_effort: bool = True,
|
|
188
|
+
event_logger: logging.Logger | None = None,
|
|
189
|
+
) -> Callable[[Any, str, str | StrEnum, dict[str, Any] | None], None]:
|
|
190
|
+
"""Create a reusable audit hook backed by an application record factory."""
|
|
191
|
+
|
|
192
|
+
def emit_audit_event(
|
|
193
|
+
repo: Any,
|
|
194
|
+
actor_id: str,
|
|
195
|
+
action: str | StrEnum,
|
|
196
|
+
details: dict[str, Any] | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
recorder = AuditRecorder(
|
|
199
|
+
repo,
|
|
200
|
+
record_factory,
|
|
201
|
+
request_id_provider=request_id_provider,
|
|
202
|
+
job_id_provider=job_id_provider,
|
|
203
|
+
event_logger=event_logger,
|
|
204
|
+
)
|
|
205
|
+
if best_effort:
|
|
206
|
+
recorder.emit_best_effort(
|
|
207
|
+
action,
|
|
208
|
+
actor_id=actor_id,
|
|
209
|
+
metadata=details,
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
recorder.emit(
|
|
214
|
+
action,
|
|
215
|
+
actor_id=actor_id,
|
|
216
|
+
metadata=details,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return emit_audit_event
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def log_event(
|
|
223
|
+
repo: Any,
|
|
224
|
+
actor_id: str,
|
|
225
|
+
action: str | StrEnum,
|
|
226
|
+
details: dict[str, Any] | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Best-effort audit logging for cpkit-backed service actions."""
|
|
229
|
+
if _audit_record_factory is None:
|
|
230
|
+
raise RuntimeError("cpkit audit logging has not been configured.")
|
|
231
|
+
|
|
232
|
+
create_audit_event_hook(
|
|
233
|
+
_audit_record_factory,
|
|
234
|
+
event_logger=logger,
|
|
235
|
+
)(
|
|
236
|
+
repo,
|
|
237
|
+
actor_id,
|
|
238
|
+
action,
|
|
239
|
+
details,
|
|
240
|
+
)
|