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/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))
|