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.
Files changed (90) hide show
  1. cpkit/README.md +82 -0
  2. cpkit/__init__.py +42 -0
  3. cpkit/admin.py +53 -0
  4. cpkit/app.py +130 -0
  5. cpkit/audit/README.md +33 -0
  6. cpkit/audit/__init__.py +44 -0
  7. cpkit/audit/events_service.py +36 -0
  8. cpkit/audit/recorder.py +240 -0
  9. cpkit/audit/repository.py +64 -0
  10. cpkit/audit/router.py +46 -0
  11. cpkit/audit/service.py +34 -0
  12. cpkit/audit/types.py +38 -0
  13. cpkit/auth/README.md +38 -0
  14. cpkit/auth/__init__.py +123 -0
  15. cpkit/auth/api_key_router.py +52 -0
  16. cpkit/auth/api_key_service.py +143 -0
  17. cpkit/auth/api_keys.py +143 -0
  18. cpkit/auth/bundle.py +113 -0
  19. cpkit/auth/claims.py +37 -0
  20. cpkit/auth/config.py +162 -0
  21. cpkit/auth/dependencies.py +155 -0
  22. cpkit/auth/oidc.py +703 -0
  23. cpkit/auth/redirects.py +12 -0
  24. cpkit/auth/repositories.py +181 -0
  25. cpkit/auth/router.py +214 -0
  26. cpkit/auth/secrets.py +78 -0
  27. cpkit/auth/types.py +49 -0
  28. cpkit/bundle.py +252 -0
  29. cpkit/cli/README.md +21 -0
  30. cpkit/cli/__init__.py +14 -0
  31. cpkit/cli/__main__.py +5 -0
  32. cpkit/cli/base.py +200 -0
  33. cpkit/cli/schema.py +30 -0
  34. cpkit/cli/server.py +22 -0
  35. cpkit/config/README.md +13 -0
  36. cpkit/config/__init__.py +9 -0
  37. cpkit/config/env.py +31 -0
  38. cpkit/db/README.md +24 -0
  39. cpkit/db/__init__.py +23 -0
  40. cpkit/db/postgres.py +252 -0
  41. cpkit/dependencies.py +73 -0
  42. cpkit/errors/README.md +22 -0
  43. cpkit/errors/__init__.py +35 -0
  44. cpkit/errors/http.py +45 -0
  45. cpkit/errors/repository.py +32 -0
  46. cpkit/errors/service.py +74 -0
  47. cpkit/jobs/README.md +29 -0
  48. cpkit/jobs/__init__.py +49 -0
  49. cpkit/jobs/maintenance.py +15 -0
  50. cpkit/jobs/repository.py +306 -0
  51. cpkit/jobs/router.py +105 -0
  52. cpkit/jobs/service.py +138 -0
  53. cpkit/jobs/types.py +67 -0
  54. cpkit/jobs/worker.py +200 -0
  55. cpkit/logging/README.md +19 -0
  56. cpkit/logging/__init__.py +13 -0
  57. cpkit/logging/context.py +29 -0
  58. cpkit/logging/middleware.py +55 -0
  59. cpkit/logging/setup.py +81 -0
  60. cpkit/playbooks/README.md +25 -0
  61. cpkit/playbooks/__init__.py +40 -0
  62. cpkit/playbooks/ansible.py +359 -0
  63. cpkit/playbooks/repository.py +95 -0
  64. cpkit/playbooks/router.py +95 -0
  65. cpkit/playbooks/service.py +232 -0
  66. cpkit/playbooks/types.py +43 -0
  67. cpkit/repository.py +63 -0
  68. cpkit/resources/__init__.py +17 -0
  69. cpkit/resources/ddl.sql +150 -0
  70. cpkit/settings/README.md +22 -0
  71. cpkit/settings/__init__.py +18 -0
  72. cpkit/settings/keys.py +30 -0
  73. cpkit/settings/repository.py +108 -0
  74. cpkit/settings/router.py +62 -0
  75. cpkit/settings/service.py +105 -0
  76. cpkit/settings/types.py +25 -0
  77. cpkit/time.py +4 -0
  78. cpkit/webapp/README.md +71 -0
  79. cpkit/webapp/__init__.py +12 -0
  80. cpkit/webapp/index.html +970 -0
  81. cpkit/webapp/script.js +1690 -0
  82. cpkit/webapp/style.css +1561 -0
  83. cpkit-0.1.0.dist-info/METADATA +105 -0
  84. cpkit-0.1.0.dist-info/RECORD +90 -0
  85. cpkit-0.1.0.dist-info/WHEEL +4 -0
  86. cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  87. resources/README.md +16 -0
  88. resources/repository_maintenance_guide.md +90 -0
  89. resources/webapp_extension_guide.md +706 -0
  90. resources/webapp_extension_template.js +130 -0
cpkit/auth/bundle.py ADDED
@@ -0,0 +1,113 @@
1
+ """Bundled authentication wiring for cpkit FastAPI apps."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from cpkit.audit import create_audit_event_hook
10
+
11
+ from .dependencies import AuthDependencies, create_auth_dependencies
12
+ from .oidc import OIDCManager
13
+ from .router import create_oidc_router
14
+ from .secrets import decrypt_secret, encrypt_secret, validate_secret_crypto_config
15
+ from .types import OIDCSessionRecord
16
+
17
+ DEFAULT_OIDC_SESSION_COOKIE_NAME = "cp_session"
18
+ DEFAULT_READONLY_ROLES = ("CP_READONLY",)
19
+ DEFAULT_USER_ROLES = ("CP_USER", "CP_ADMIN")
20
+ DEFAULT_ADMIN_ROLES = ("CP_ADMIN",)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AuthBundle:
25
+ """Standard auth objects for a cpkit app."""
26
+
27
+ oidc: OIDCManager
28
+ dependencies: AuthDependencies
29
+ router: APIRouter
30
+
31
+ @property
32
+ def require_authenticated(self):
33
+ return self.dependencies.require_authenticated
34
+
35
+ @property
36
+ def require_user(self):
37
+ return self.dependencies.require_user
38
+
39
+ @property
40
+ def require_readonly(self):
41
+ return self.dependencies.require_readonly
42
+
43
+ @property
44
+ def require_admin(self):
45
+ return self.dependencies.require_admin
46
+
47
+ @property
48
+ def get_access_scope(self):
49
+ return self.dependencies.get_access_scope
50
+
51
+ @property
52
+ def get_audit_actor(self):
53
+ return self.dependencies.get_audit_actor
54
+
55
+
56
+ def create_auth_bundle(
57
+ *,
58
+ get_repo: Callable[..., Any],
59
+ audit_record_factory: Callable[..., Any],
60
+ session_cookie_name: str = DEFAULT_OIDC_SESSION_COOKIE_NAME,
61
+ readonly_roles: tuple[Any, ...] = DEFAULT_READONLY_ROLES,
62
+ user_roles: tuple[Any, ...] = DEFAULT_USER_ROLES,
63
+ admin_roles: tuple[Any, ...] = DEFAULT_ADMIN_ROLES,
64
+ login_event: str = "LOGIN",
65
+ logout_event: str = "LOGOUT",
66
+ missing_api_key_headers_detail: str = (
67
+ "X-CP-Access-Key, X-CP-Signature, and X-Timestamp are required."
68
+ ),
69
+ ) -> AuthBundle:
70
+ """Create standard OIDC/API-key auth for a cpkit application."""
71
+ oidc = OIDCManager(
72
+ encrypt_secret=encrypt_secret,
73
+ decrypt_secret=decrypt_secret,
74
+ session_record_factory=OIDCSessionRecord,
75
+ session_cookie_name=session_cookie_name,
76
+ validate_secret_crypto_config=validate_secret_crypto_config,
77
+ missing_api_key_headers_detail=missing_api_key_headers_detail,
78
+ )
79
+ dependencies = create_auth_dependencies(
80
+ oidc,
81
+ get_repo=get_repo,
82
+ session_cookie_name=session_cookie_name,
83
+ readonly_roles=readonly_roles,
84
+ user_roles=user_roles,
85
+ admin_roles=admin_roles,
86
+ )
87
+ emit_auth_event = create_audit_event_hook(
88
+ audit_record_factory,
89
+ best_effort=False,
90
+ )
91
+
92
+ def log_auth_event(
93
+ repo: Any,
94
+ actor_id: str,
95
+ action: str,
96
+ details: dict[str, Any] | None = None,
97
+ ) -> None:
98
+ event_type = login_event if action == "LOGIN" else logout_event
99
+ emit_auth_event(
100
+ repo,
101
+ actor_id,
102
+ event_type,
103
+ details,
104
+ )
105
+
106
+ router = create_oidc_router(
107
+ oidc,
108
+ get_repo=get_repo,
109
+ require_authenticated=dependencies.require_authenticated,
110
+ get_audit_actor=dependencies.get_audit_actor,
111
+ audit_event_hook=log_auth_event,
112
+ )
113
+ return AuthBundle(oidc=oidc, dependencies=dependencies, router=router)
cpkit/auth/claims.py ADDED
@@ -0,0 +1,37 @@
1
+ """Claim normalization helpers."""
2
+
3
+ from typing import Any
4
+
5
+ from cpkit.config import safe_csv_set
6
+
7
+
8
+ def claim_groups(claim_value: Any) -> set[str]:
9
+ """Normalize a groups claim into a trimmed set of group names."""
10
+ if claim_value is None:
11
+ return set()
12
+ if isinstance(claim_value, str):
13
+ return (
14
+ safe_csv_set(claim_value)
15
+ if "," in claim_value
16
+ else ({claim_value.strip()} if claim_value.strip() else set())
17
+ )
18
+ if isinstance(claim_value, (list, tuple, set)):
19
+ return {str(v).strip() for v in claim_value if str(v).strip()}
20
+ return set()
21
+
22
+
23
+ def claims_groups(
24
+ claims: dict[str, Any], groups_claim_name: str = "groups"
25
+ ) -> set[str]:
26
+ """Extract the configured groups claim from a JWT or synthetic claims payload."""
27
+ return claim_groups(claims.get(groups_claim_name))
28
+
29
+
30
+ def jsonable_role_groups(role_groups: dict[Any, Any]) -> dict[str, list[str]]:
31
+ """Convert role-to-groups mappings into JSON-friendly sorted lists."""
32
+ normalized: dict[str, list[str]] = {}
33
+ for role_name, groups in role_groups.items():
34
+ normalized[str(getattr(role_name, "value", role_name))] = sorted(
35
+ claim_groups(groups)
36
+ )
37
+ return normalized
cpkit/auth/config.py ADDED
@@ -0,0 +1,162 @@
1
+ """OIDC configuration derived from framework settings."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from cpkit.config import as_bool, safe_csv_set, safe_json_string_dict
7
+ from cpkit.settings import FrameworkSettingKey
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class OIDCConfig:
12
+ """Configuration derived from settings for OIDC login and authz."""
13
+
14
+ enabled: bool = False
15
+ issuer_url: str = ""
16
+ client_id: str = ""
17
+ client_secret: str = ""
18
+ scopes: str = "openid profile email offline_access"
19
+ audience: str = ""
20
+ extra_auth_params_raw: str = "{}"
21
+ redirect_uri: str = ""
22
+ login_path: str = "/api/auth/login"
23
+ session_max_age_seconds: int = 2592000
24
+ refresh_leeway_seconds: int = 60
25
+ cookie_secure: bool = False
26
+ cookie_samesite: str = "lax"
27
+ cookie_domain: str | None = None
28
+ verify_audience: bool = False
29
+ ui_username_claim: str = "preferred_username"
30
+ readonly_groups_raw: str = ""
31
+ user_groups_raw: str = ""
32
+ admin_groups_raw: str = ""
33
+ groups_claim_name: str = "groups"
34
+ cache_ttl_seconds: int = 300
35
+ api_key_signature_ttl_seconds: int = 300
36
+
37
+ @classmethod
38
+ def from_repo(cls, repo: Any) -> "OIDCConfig":
39
+ settings = {setting.key: setting for setting in repo.list_settings()}
40
+ return cls.from_settings(settings)
41
+
42
+ @classmethod
43
+ def from_settings(cls, settings: dict[Any, Any]) -> "OIDCConfig":
44
+ SettingKey = FrameworkSettingKey
45
+ enabled = as_bool(settings[SettingKey.oidc_enabled].value, default=False)
46
+ if not enabled:
47
+ return cls(
48
+ enabled=False,
49
+ cache_ttl_seconds=int(
50
+ settings[SettingKey.oidc_cache_ttl_seconds].value
51
+ ),
52
+ api_key_signature_ttl_seconds=int(
53
+ settings[SettingKey.auth_api_key_signature_ttl_seconds].value
54
+ ),
55
+ )
56
+
57
+ return cls(
58
+ enabled=enabled,
59
+ issuer_url=settings[SettingKey.oidc_issuer_url].value.rstrip("/"),
60
+ client_id=settings[SettingKey.oidc_client_id].value,
61
+ client_secret=settings[SettingKey.oidc_client_secret].value,
62
+ scopes=settings[SettingKey.oidc_scopes].value,
63
+ audience=settings[SettingKey.oidc_audience].value,
64
+ extra_auth_params_raw=settings[SettingKey.oidc_extra_auth_params].value,
65
+ redirect_uri=settings[SettingKey.oidc_redirect_uri].value,
66
+ login_path=settings[SettingKey.oidc_login_path].value,
67
+ session_max_age_seconds=int(
68
+ settings[SettingKey.oidc_session_max_age_seconds].value
69
+ ),
70
+ refresh_leeway_seconds=int(
71
+ settings[SettingKey.oidc_refresh_leeway_seconds].value
72
+ ),
73
+ cookie_secure=as_bool(
74
+ settings[SettingKey.oidc_cookie_secure].value,
75
+ default=False,
76
+ ),
77
+ cookie_samesite=settings[SettingKey.oidc_cookie_samesite].value.lower(),
78
+ cookie_domain=settings[SettingKey.oidc_cookie_domain].value or None,
79
+ verify_audience=as_bool(
80
+ settings[SettingKey.oidc_verify_audience].value,
81
+ default=False,
82
+ ),
83
+ ui_username_claim=settings[SettingKey.oidc_ui_username_claim].value,
84
+ readonly_groups_raw=settings[SettingKey.oidc_authz_readonly_groups].value,
85
+ user_groups_raw=settings[SettingKey.oidc_authz_user_groups].value,
86
+ admin_groups_raw=settings[SettingKey.oidc_authz_admin_groups].value,
87
+ groups_claim_name=settings[SettingKey.oidc_authz_groups_claim].value,
88
+ cache_ttl_seconds=int(settings[SettingKey.oidc_cache_ttl_seconds].value),
89
+ api_key_signature_ttl_seconds=int(
90
+ settings[SettingKey.auth_api_key_signature_ttl_seconds].value
91
+ ),
92
+ )
93
+
94
+ @property
95
+ def role_groups(self) -> dict[str, set[str]]:
96
+ """Map application role names to the configured OIDC groups that grant them."""
97
+ return {
98
+ "CP_READONLY": safe_csv_set(self.readonly_groups_raw),
99
+ "CP_USER": safe_csv_set(self.user_groups_raw),
100
+ "CP_ADMIN": safe_csv_set(self.admin_groups_raw),
101
+ }
102
+
103
+ @property
104
+ def authorized_groups(self) -> set[str]:
105
+ """Return the union of all OIDC groups allowed to access the application."""
106
+ role_groups = self.role_groups
107
+ if not role_groups:
108
+ return set()
109
+ groups: set[str] = set()
110
+ for values in role_groups.values():
111
+ groups.update(values)
112
+ return groups
113
+
114
+ def validate(self) -> None:
115
+ """Validate startup configuration before the app begins serving requests."""
116
+ if not self.enabled:
117
+ return
118
+
119
+ missing = []
120
+ if not self.issuer_url:
121
+ missing.append("oidc_issuer_url")
122
+ if not self.client_id:
123
+ missing.append("oidc_client_id")
124
+ if not self.client_secret:
125
+ missing.append("oidc_client_secret")
126
+
127
+ if missing:
128
+ raise RuntimeError(
129
+ f"OIDC is enabled but missing required settings: {', '.join(missing)}"
130
+ )
131
+
132
+ if self.cookie_samesite not in {"lax", "strict", "none"}:
133
+ raise RuntimeError("oidc_cookie_samesite must be one of: lax, strict, none")
134
+
135
+ if self.cookie_samesite == "none" and not self.cookie_secure:
136
+ raise RuntimeError(
137
+ "oidc_cookie_secure must be true when oidc_cookie_samesite=none"
138
+ )
139
+
140
+ if not self.ui_username_claim:
141
+ raise RuntimeError(
142
+ "oidc_ui_username_claim must be set when OIDC is enabled"
143
+ )
144
+
145
+ if not self.groups_claim_name:
146
+ raise RuntimeError(
147
+ "oidc_authz_groups_claim must be set when OIDC is enabled"
148
+ )
149
+
150
+ if not self.authorized_groups:
151
+ raise RuntimeError(
152
+ "At least one of oidc_authz_readonly_groups, oidc_authz_user_groups, oidc_authz_admin_groups must include a group when OIDC is enabled"
153
+ )
154
+
155
+ self.extra_auth_params()
156
+
157
+ def extra_auth_params(self) -> dict[str, str]:
158
+ """Return extra authorization request parameters as a string dictionary."""
159
+ try:
160
+ return safe_json_string_dict(self.extra_auth_params_raw, default={})
161
+ except ValueError as exc:
162
+ raise ValueError("oidc_extra_auth_params must be a JSON object") from exc
@@ -0,0 +1,155 @@
1
+ """FastAPI authentication dependency helpers for cpkit apps."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from fastapi import Depends, Request, Security
8
+ from fastapi.security import APIKeyHeader
9
+
10
+ from .claims import claims_groups
11
+ from .oidc import OIDCManager
12
+
13
+ ACCESS_KEY_HEADER_NAME = "X-CP-Access-Key"
14
+ SIGNATURE_HEADER_NAME = "X-CP-Signature"
15
+ TIMESTAMP_HEADER_NAME = "X-Timestamp"
16
+
17
+ access_key_scheme = APIKeyHeader(
18
+ name=ACCESS_KEY_HEADER_NAME,
19
+ scheme_name="XAccessKey",
20
+ auto_error=False,
21
+ )
22
+ signature_scheme = APIKeyHeader(
23
+ name=SIGNATURE_HEADER_NAME,
24
+ scheme_name="XSignature",
25
+ auto_error=False,
26
+ )
27
+ timestamp_scheme = APIKeyHeader(
28
+ name=TIMESTAMP_HEADER_NAME,
29
+ scheme_name="XTimestamp",
30
+ auto_error=False,
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AuthDependencies:
36
+ """Container for standard cpkit auth dependency callables."""
37
+
38
+ require_authenticated: Callable[..., Any]
39
+ require_user: Callable[..., Any]
40
+ require_readonly: Callable[..., Any]
41
+ require_admin: Callable[..., Any]
42
+ get_access_scope: Callable[[dict[str, Any]], tuple[list[str], bool]]
43
+ get_audit_actor: Callable[..., str]
44
+
45
+
46
+ def create_auth_dependencies(
47
+ oidc: OIDCManager,
48
+ *,
49
+ get_repo: Callable[..., Any],
50
+ readonly_roles: tuple[Any, ...],
51
+ user_roles: tuple[Any, ...],
52
+ admin_roles: tuple[Any, ...],
53
+ session_cookie_name: str | None = None,
54
+ ) -> AuthDependencies:
55
+ """Create the standard auth dependencies for a cpkit FastAPI app."""
56
+ effective_session_cookie_name = session_cookie_name or oidc.session_cookie_name
57
+
58
+ async def require_authenticated(
59
+ request: Request,
60
+ repo: Any = Depends(get_repo),
61
+ access_key: str | None = Security(access_key_scheme),
62
+ signature: str | None = Security(signature_scheme),
63
+ timestamp: str | None = Security(timestamp_scheme),
64
+ ) -> dict[str, Any]:
65
+ """Return claims for the current caller, regardless of auth transport."""
66
+ oidc.load_config(repo)
67
+ session_token = (
68
+ request.cookies.get(effective_session_cookie_name)
69
+ if effective_session_cookie_name
70
+ else None
71
+ )
72
+ return await oidc.current_claims(
73
+ request,
74
+ repo,
75
+ session_token=session_token,
76
+ access_key=access_key,
77
+ signature=signature,
78
+ timestamp=timestamp,
79
+ )
80
+
81
+ def require_user(
82
+ claims: dict[str, Any] = Security(require_authenticated),
83
+ ) -> dict[str, Any]:
84
+ """Require a role that permits mutating application operations."""
85
+ return oidc.ensure_any_role(claims, *user_roles)
86
+
87
+ def require_readonly(
88
+ request: Request,
89
+ claims: dict[str, Any] = Security(require_authenticated),
90
+ ) -> dict[str, Any]:
91
+ """Allow read-only roles on GET and require user roles on writes."""
92
+ if request.method.upper() == "GET":
93
+ return oidc.ensure_any_role(claims, *readonly_roles, *user_roles)
94
+ return oidc.ensure_any_role(claims, *user_roles)
95
+
96
+ def require_admin(
97
+ claims: dict[str, Any] = Security(require_authenticated),
98
+ ) -> dict[str, Any]:
99
+ """Require an administrator role."""
100
+ return oidc.ensure_any_role(claims, *admin_roles)
101
+
102
+ def get_access_scope(claims: dict[str, Any]) -> tuple[list[str], bool]:
103
+ """Return normalized caller groups plus whether the caller is an admin."""
104
+ groups_claim_name = str(
105
+ claims.get("_groups_claim_name", oidc.config.groups_claim_name)
106
+ )
107
+ groups = sorted(claims_groups(claims, groups_claim_name))
108
+
109
+ effective_roles = (
110
+ claims.get("_role_groups")
111
+ if isinstance(claims.get("_role_groups"), dict)
112
+ else oidc.config.role_groups
113
+ )
114
+ is_admin = any(
115
+ _role_groups_intersect(effective_roles, role, groups)
116
+ for role in admin_roles
117
+ )
118
+ return groups, is_admin
119
+
120
+ def get_audit_actor(
121
+ claims: dict[str, Any] = Security(require_authenticated),
122
+ ) -> str:
123
+ """Return the identifier that should be written into audit logs."""
124
+ if claims.get("auth_type") == "api_key":
125
+ return str(claims.get("access_key") or "anonymous")
126
+
127
+ username = claims.get(oidc.config.ui_username_claim) or claims.get("sub")
128
+ return str(username or "anonymous")
129
+
130
+ return AuthDependencies(
131
+ require_authenticated=require_authenticated,
132
+ require_user=require_user,
133
+ require_readonly=require_readonly,
134
+ require_admin=require_admin,
135
+ get_access_scope=get_access_scope,
136
+ get_audit_actor=get_audit_actor,
137
+ )
138
+
139
+
140
+ def _role_groups_intersect(
141
+ role_groups_by_name: dict[Any, Any],
142
+ role: Any,
143
+ groups: list[str],
144
+ ) -> bool:
145
+ role_groups = role_groups_by_name.get(role, set()) or role_groups_by_name.get(
146
+ _role_value(role),
147
+ set(),
148
+ )
149
+ return bool(role_groups) and not claims_groups({"groups": role_groups}).isdisjoint(
150
+ groups
151
+ )
152
+
153
+
154
+ def _role_value(role: Any) -> str:
155
+ return str(getattr(role, "value", role))