Publish curated Unraid-AppOps-Gateway source from 451646fee806a47e3256731766980a05a262dcdd
This commit is contained in:
1 parent
532f3d9f26
commit
0a82153d95
43 files changed
+17722
-93
No files matched your search
@@ -176,6 +176,8 @@ ACTIONS = [
|
||||
'authentik.force_sync',
|
||||
'authentik.mass_edit',
|
||||
'authentik.unknown_mutation',
|
||||
'forge.oidc.provision',
|
||||
'forge.secrets.prepare',
|
||||
'tautulli.terminate_session',
|
||||
'tautulli.delete_newsletter',
|
||||
'tautulli.delete_notification_log',
|
||||
@@ -386,6 +388,8 @@ ACTION_RISK = {
|
||||
'authentik.force_sync': 'high',
|
||||
'authentik.mass_edit': 'critical',
|
||||
'authentik.unknown_mutation': 'critical',
|
||||
'forge.oidc.provision': 'critical',
|
||||
'forge.secrets.prepare': 'critical',
|
||||
'tautulli.terminate_session': 'high',
|
||||
'tautulli.delete_newsletter': 'critical',
|
||||
'tautulli.delete_notification_log': 'critical',
|
||||
@@ -531,6 +535,10 @@ def action_category(action: str) -> str:
|
||||
|
||||
|
||||
def action_default_target(action: str) -> str:
|
||||
if action == 'forge.oidc.provision':
|
||||
return 'itworx-forge'
|
||||
if action == 'forge.secrets.prepare':
|
||||
return 'itworx-forge'
|
||||
if action == 'controller.artifact.promote':
|
||||
return 'tower/deployment-controller'
|
||||
if action == 'controller.managed-storage.initialize':
|
||||
|
||||
@@ -8,6 +8,7 @@ runtime topology before it can mutate production.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
@@ -27,6 +28,7 @@ from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from ..schemas.deployment import DeploymentPlanV1
|
||||
from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1
|
||||
from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1
|
||||
|
||||
_REQUEST_ID = re.compile(r"^[0-9a-f]{64}$")
|
||||
_MAX_DELIVERY_STATUS_BYTES = 512 * 1024
|
||||
@@ -35,6 +37,18 @@ _MAX_CONTROLLER_HEALTH_BYTES = 64 * 1024
|
||||
_WORKMESH_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
||||
_WORKMESH_PREFLIGHT_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_WORKMESH_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
_FORGE_SECRET_DELIVERY_PATH = "/v1/appops/forge/bootstrap/secrets"
|
||||
_MAX_FORGE_SECRET_DELIVERY_BODY_BYTES = 16 * 1024
|
||||
_MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate_json_key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
class DeploymentBrokerClient:
|
||||
@@ -197,6 +211,8 @@ class DeploymentBrokerClient:
|
||||
value = decoded.get(field)
|
||||
if isinstance(value, str) and len(value) <= 256:
|
||||
rejected[field] = value
|
||||
elif field == "noMutation" and isinstance(value, bool):
|
||||
rejected[field] = value
|
||||
return rejected
|
||||
return {"status": str(decoded.get("status") or "ok"), **decoded}
|
||||
|
||||
@@ -314,6 +330,184 @@ class DeploymentBrokerClient:
|
||||
payload={"profile": profile.model_dump(mode="json")},
|
||||
)
|
||||
|
||||
def forge_bootstrap_apply(
|
||||
self,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
secret: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Submit only the fixed, preflight-bound Forge first-install profile."""
|
||||
result = self._request(
|
||||
"POST",
|
||||
"/v1/appops/forge/bootstrap/apply",
|
||||
secret=secret,
|
||||
payload={
|
||||
"profile": profile.model_dump(mode="json"),
|
||||
"idempotencyKey": idempotency_key,
|
||||
},
|
||||
error_response_fields=frozenset({
|
||||
"apiVersion", "request", "repository", "revision", "operation",
|
||||
"workspaceImageDigest", "frontdoorImageDigest", "noMutation",
|
||||
}),
|
||||
)
|
||||
if result.get("status") == "preflight_changed":
|
||||
result.pop("reason", None)
|
||||
return result
|
||||
|
||||
def forge_secret_delivery(
|
||||
self,
|
||||
profile: ForgeBootstrapPreflightV1,
|
||||
*,
|
||||
config_bytes: bytes,
|
||||
oidc_client_secret: bytes,
|
||||
authentik_read_token: bytes,
|
||||
secret: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Send the single fixed Forge credential bundle request.
|
||||
|
||||
Any response after transmission is treated as an unknown outcome unless
|
||||
it is a bounded, strict JSON object. Caller-side validation still owns
|
||||
the complete success contract and never echoes this internal response.
|
||||
"""
|
||||
configuration_error = self.configuration_error()
|
||||
if configuration_error is not None:
|
||||
return {
|
||||
"status": "missing_config",
|
||||
"reason": "forge_secret_delivery_broker_configuration_invalid",
|
||||
}
|
||||
if (
|
||||
not isinstance(secret, str)
|
||||
or len(secret) < 32
|
||||
or not secret.isascii()
|
||||
or not secret.isprintable()
|
||||
or not isinstance(config_bytes, bytes)
|
||||
or not 1 <= len(config_bytes) <= 8192
|
||||
or not isinstance(oidc_client_secret, bytes)
|
||||
or not 32 <= len(oidc_client_secret) <= 255
|
||||
or any(byte < 0x20 or byte > 0x7E for byte in oidc_client_secret)
|
||||
or not isinstance(authentik_read_token, bytes)
|
||||
or not 32 <= len(authentik_read_token) <= 256
|
||||
or any(byte < 0x21 or byte > 0x7E for byte in authentik_read_token)
|
||||
):
|
||||
return {"status": "missing_secret", "reason": "forge_secret_delivery_inputs_invalid"}
|
||||
|
||||
payload = {
|
||||
"apiVersion": "itworx.forge-secret-delivery/v1",
|
||||
"target": "itworx-forge",
|
||||
"profile": profile.model_dump(mode="json"),
|
||||
"configBase64": base64.b64encode(config_bytes).decode("ascii"),
|
||||
"oidcClientSecretBase64": base64.b64encode(oidc_client_secret).decode("ascii"),
|
||||
"authentikReadTokenBase64": base64.b64encode(authentik_read_token).decode("ascii"),
|
||||
}
|
||||
body = self._canonical_payload(payload)
|
||||
payload.clear()
|
||||
if len(body) > _MAX_FORGE_SECRET_DELIVERY_BODY_BYTES:
|
||||
return {"status": "missing_secret", "reason": "forge_secret_delivery_request_too_large"}
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
path = _FORGE_SECRET_DELIVERY_PATH
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
"Content-Type": "application/json",
|
||||
"X-ITWorx-Deployment-Timestamp": timestamp,
|
||||
"X-ITWorx-Deployment-Nonce": nonce,
|
||||
"X-ITWorx-Deployment-Signature": self._signature(
|
||||
secret, "POST", path, timestamp, nonce, body
|
||||
),
|
||||
}
|
||||
response_body = bytearray()
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=self.timeout_seconds,
|
||||
verify=self._verify_option(),
|
||||
follow_redirects=False,
|
||||
) as client, client.stream(
|
||||
"POST",
|
||||
f"{self.base_url}{path}",
|
||||
content=body,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"status": "outcome_unknown",
|
||||
"reason": "forge_secret_delivery_controller_rejected",
|
||||
}
|
||||
content_length = response.headers.get("content-length")
|
||||
if content_length is not None and (
|
||||
not content_length.isascii()
|
||||
or not content_length.isdecimal()
|
||||
or len(content_length) > 10
|
||||
or int(content_length) > _MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES
|
||||
):
|
||||
return {
|
||||
"status": "outcome_unknown",
|
||||
"reason": "forge_secret_delivery_response_invalid",
|
||||
}
|
||||
for chunk in response.iter_bytes(chunk_size=16 * 1024):
|
||||
response_body.extend(chunk)
|
||||
if len(response_body) > _MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES:
|
||||
response_body.clear()
|
||||
return {
|
||||
"status": "outcome_unknown",
|
||||
"reason": "forge_secret_delivery_response_invalid",
|
||||
}
|
||||
except Exception: # noqa: BLE001 - exception text can contain held credentials.
|
||||
response_body.clear()
|
||||
return {
|
||||
"status": "outcome_unknown",
|
||||
"reason": "forge_secret_delivery_transport_unknown",
|
||||
}
|
||||
|
||||
try:
|
||||
decoded = json.loads(
|
||||
bytes(response_body).decode("utf-8"),
|
||||
object_pairs_hook=_unique_json_object,
|
||||
)
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
response_body.clear()
|
||||
return {
|
||||
"status": "outcome_unknown",
|
||||
"reason": "forge_secret_delivery_response_invalid",
|
||||
}
|
||||
response_body.clear()
|
||||
if not isinstance(decoded, dict):
|
||||
return {
|
||||
"status": "outcome_unknown",
|
||||
"reason": "forge_secret_delivery_response_invalid",
|
||||
}
|
||||
return {"status": "response", "response": decoded}
|
||||
|
||||
def forge_bootstrap_resume(self, request_id: str, *, secret: str) -> dict[str, Any]:
|
||||
"""Resume only one recorded pre-migration credentials blocker."""
|
||||
if not _REQUEST_ID.fullmatch(request_id):
|
||||
return {"status": "invalid_request_id"}
|
||||
result = self._request(
|
||||
"POST",
|
||||
f"/v1/appops/forge/bootstrap/resume/{request_id}",
|
||||
secret=secret,
|
||||
payload={},
|
||||
error_response_fields=frozenset({
|
||||
"apiVersion", "request", "repository", "revision", "operation",
|
||||
"workspaceImageDigest", "frontdoorImageDigest", "noMutation",
|
||||
}),
|
||||
)
|
||||
if result.get("status") == "preflight_changed":
|
||||
result.pop("reason", None)
|
||||
return result
|
||||
|
||||
def forge_bootstrap_status(self, request_id: str, *, secret: str) -> dict[str, Any]:
|
||||
"""Read one fixed Forge first-install request; never submits mutations."""
|
||||
if not _REQUEST_ID.fullmatch(request_id):
|
||||
return {"status": "invalid_request_id"}
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v1/appops/forge/bootstrap/status/{request_id}",
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
def enrollment_capabilities(self, *, secret: str) -> dict[str, Any]:
|
||||
"""Read the bootstrap-protected enrollment controller capability."""
|
||||
return self._request(
|
||||
|
||||
@@ -0,0 +1,923 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.time import utc_now
|
||||
from ..db.models import DeploymentJob, Policy
|
||||
from ..schemas.enrollment import MANAGED_DOCKER_APPDATA_ROOT
|
||||
from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1
|
||||
from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1
|
||||
from .audit_logger import log_event
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
from .enrollment_plane import EnrollmentPlane
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret
|
||||
|
||||
_API_VERSION = "itworx.forge-first-install/v1"
|
||||
_PREFLIGHT_API_VERSION = "itworx.forge-bootstrap-preflight/v1"
|
||||
_APPLICATION_ID = "itworx-forge"
|
||||
_REPOSITORY = "jens/itworx-forge"
|
||||
_OPERATION = "forge_first_install"
|
||||
_SECRET_REF = "FORGE_DEPLOYMENT_BROKER"
|
||||
_FORGE_SOURCE_URL = "https://gitea.example.com/Jens/itworx-forge"
|
||||
_FORGE_PRIVATE_PACKAGE_OWNER_SETTING = "FORGE_PRIVATE_PACKAGE_OWNER"
|
||||
_FORGE_PRIVATE_PACKAGE_OWNER_PATTERN = re.compile(r"[a-z0-9][a-z0-9_-]{0,38}\Z")
|
||||
_FORGE_OIDC_ISSUER = "https://auth.example-host.com/application/o/itworx-forge/"
|
||||
_FORGE_OIDC_CLIENT_ID = "itworx-forge"
|
||||
_FORGE_OIDC_CALLBACK = "https://forge.example.com/auth/callback"
|
||||
_FORGE_OIDC_GROUPS = ["forge-owners", "forge-operators", "forge-viewers"]
|
||||
_FORGE_OIDC_SCOPES = ["email", "openid", "profile"]
|
||||
_FORGE_OIDC_SCOPE_MAPPING_EXPRESSION_SHA256 = {
|
||||
"openid": "38e58da331cc5378d4f269389242aecb80adf30093c405524dc1ab5aab9d452f",
|
||||
"email": "d1200eabeffc3b7f379e9c1636367b69befb0b1ac2b5d82b7be20e78667f2b94",
|
||||
"profile": "648b3f6dbf2212940309a581b239c9996562ca017a89be22bcfa93f9aa2c26ae",
|
||||
}
|
||||
_FORGE_OIDC_PROFILE_MAPPING_NAME = "ITWorx Forge profile and groups claim v1"
|
||||
_FORGE_OIDC_OPENID_MAPPING_MANAGED = "goauthentik.io/providers/oauth2/scope-openid"
|
||||
_FORGE_OIDC_EMAIL_MAPPING_MANAGED = "goauthentik.io/providers/oauth2/scope-email"
|
||||
_OIDC_ATTESTATION_FIELDS = {
|
||||
"issuer", "audience", "clientId", "clientType", "callback", "applicationId",
|
||||
"providerId", "clientSecretMatched", "authorizationFlowId", "invalidationFlowId",
|
||||
"roleGroups", "scopeMappingIds", "scopeMappingExpressionSha256",
|
||||
"profileMappingName", "standardMappingsManaged", "supportedScopes",
|
||||
"authorizationCodeFlowSupported", "signingKeyId", "signingKeyType",
|
||||
"signingKeyPrivateAvailable", "signingKeyFingerprintSha256", "signingKeyKid",
|
||||
"tokenSigningAlgorithm", "jwksKid",
|
||||
}
|
||||
_AUTHENTIK_FORGE_OIDC_BINDING_VALID_SETTING = "AUTHENTIK_FORGE_OIDC_BINDING_VALID"
|
||||
_AUTHENTIK_FORGE_OIDC_PROVIDER_ID_SETTING = "AUTHENTIK_FORGE_OIDC_PROVIDER_ID"
|
||||
_AUTHENTIK_FORGE_OIDC_SCOPE_MAPPING_SETTINGS = {
|
||||
"openid": "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING",
|
||||
"email": "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING",
|
||||
"profile": "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING",
|
||||
}
|
||||
_AUTHENTIK_SIGNING_KEY_ID_SETTING = "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID"
|
||||
_AUTHENTIK_AUTHORIZATION_FLOW_SETTING = "AUTHENTIK_OIDC_AUTHORIZATION_FLOW"
|
||||
_AUTHENTIK_INVALIDATION_FLOW_SETTING = "AUTHENTIK_OIDC_INVALIDATION_FLOW"
|
||||
_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
_HEALTHY_SERVICES = ["api", "dashboard", "itworx-forge", "postgres", "worker"]
|
||||
_PROFILE_CHECKS = {
|
||||
"deployment_apply_policy",
|
||||
"controller_trust",
|
||||
"managed_appdata_root_selection",
|
||||
"exact_main",
|
||||
"source_manifest",
|
||||
"managed_appdata",
|
||||
"proxy_network",
|
||||
"resource_collisions",
|
||||
"push_validation",
|
||||
"image_registry",
|
||||
"oidc",
|
||||
"secret_delivery",
|
||||
}
|
||||
_FAILED_CODES = {"migration_failed", "service_start_failed", "healthcheck_failed"}
|
||||
_ACTIVE_STATUSES = {"applying", "outcome_unknown", "needs_database_credentials"}
|
||||
_TERMINAL_STATUSES = {"deployed", "failed", "preflight_changed"}
|
||||
|
||||
|
||||
class ForgeBootstrapApplyError(ValueError):
|
||||
"""A fail-closed error in the fixed Forge first-install contract."""
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def canonical_preflight_receipt(job: DeploymentJob) -> str:
|
||||
"""Hash the immutable preflight receipt as stored in AppOps, not caller data."""
|
||||
receipt = {
|
||||
"apiVersion": _PREFLIGHT_API_VERSION,
|
||||
"jobId": job.id,
|
||||
"applicationId": job.application_id,
|
||||
"repository": job.repository,
|
||||
"revision": job.revision,
|
||||
"jobStatus": job.status,
|
||||
"planSha256": job.plan_sha256,
|
||||
"profile": dict(job.plan_json or {}),
|
||||
"preflight": dict(job.preflight_json or {}),
|
||||
"evidence": dict(job.evidence_json or {}),
|
||||
}
|
||||
return _sha256(receipt)
|
||||
|
||||
|
||||
def _idempotency_key(value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not _IDEMPOTENCY.fullmatch(normalized):
|
||||
raise ForgeBootstrapApplyError("Idempotency-Key must contain 8 to 128 safe characters.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _unknown_summary() -> str:
|
||||
return "The fixed Forge controller result is unknown; reconcile its status before retrying."
|
||||
|
||||
|
||||
def _preflight_ready(
|
||||
session: Session, profile: ForgeBootstrapApplyV1
|
||||
) -> tuple[DeploymentJob, str]:
|
||||
job = session.get(DeploymentJob, profile.preflight_job_id)
|
||||
if (
|
||||
not job
|
||||
or job.application_id != _APPLICATION_ID
|
||||
or job.repository != _REPOSITORY
|
||||
or job.revision != profile.revision
|
||||
or job.status != "preflight_succeeded"
|
||||
or not job.completed_at
|
||||
or job.plan_sha256 != _sha256(dict(job.plan_json or {}))
|
||||
):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"A matching passed Forge preflight receipt is required before first install."
|
||||
)
|
||||
try:
|
||||
stored_profile = ForgeBootstrapPreflightV1.model_validate(job.plan_json)
|
||||
except ValueError as exc:
|
||||
raise ForgeBootstrapApplyError("The stored Forge preflight receipt is invalid.") from exc
|
||||
if (
|
||||
stored_profile.revision != profile.revision
|
||||
or stored_profile.workspace_image_digest != profile.workspace_image_digest
|
||||
or stored_profile.frontdoor_image_digest != profile.frontdoor_image_digest
|
||||
):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"The stored Forge preflight receipt does not match this profile."
|
||||
)
|
||||
|
||||
preflight = dict(job.preflight_json or {})
|
||||
if (
|
||||
set(preflight) != {"apiVersion", "status", "blockers", "unknowns", "checks"}
|
||||
or preflight.get("apiVersion") != _PREFLIGHT_API_VERSION
|
||||
or preflight.get("status") != "passed"
|
||||
or preflight.get("blockers") != []
|
||||
or preflight.get("unknowns") != []
|
||||
):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"Forge first install requires a passed preflight with no unknown checks."
|
||||
)
|
||||
checks = preflight.get("checks")
|
||||
if not isinstance(checks, list) or len(checks) != len(_PROFILE_CHECKS):
|
||||
raise ForgeBootstrapApplyError("The stored Forge preflight checks are incomplete.")
|
||||
seen: set[str] = set()
|
||||
for check in checks:
|
||||
if (
|
||||
not isinstance(check, dict)
|
||||
or set(check) != {"name", "status", "detail"}
|
||||
or not isinstance(check.get("name"), str)
|
||||
or check["name"] not in _PROFILE_CHECKS
|
||||
or check["name"] in seen
|
||||
or check.get("status") != "passed"
|
||||
or not isinstance(check.get("detail"), str)
|
||||
or len(check["detail"]) > 256
|
||||
):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"Forge first install requires every fixed preflight check to pass."
|
||||
)
|
||||
seen.add(check["name"])
|
||||
if seen != _PROFILE_CHECKS:
|
||||
raise ForgeBootstrapApplyError("The stored Forge preflight checks are incomplete.")
|
||||
|
||||
evidence = dict(job.evidence_json or {})
|
||||
if (
|
||||
set(evidence)
|
||||
!= {"profile", "policyId", "workspaceImageDigest", "frontdoorImageDigest", "controller"}
|
||||
or evidence.get("profile") != "forge-production-bootstrap"
|
||||
or not isinstance(evidence.get("policyId"), str)
|
||||
or evidence.get("workspaceImageDigest") != profile.workspace_image_digest
|
||||
or evidence.get("frontdoorImageDigest") != profile.frontdoor_image_digest
|
||||
or not isinstance(evidence.get("controller"), dict)
|
||||
):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"The stored Forge preflight evidence does not match this profile."
|
||||
)
|
||||
current_policy_id = _allowed_policy_id(session)
|
||||
if current_policy_id is None or current_policy_id != evidence["policyId"]:
|
||||
raise ForgeBootstrapApplyError(
|
||||
"The exact-target Forge deployment policy changed after preflight."
|
||||
)
|
||||
if not _attestations_ready(evidence["controller"], profile):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"The stored Forge preflight lacks verified controller attestations."
|
||||
)
|
||||
receipt_sha = canonical_preflight_receipt(job)
|
||||
if profile.preflight_sha256 != receipt_sha:
|
||||
raise ForgeBootstrapApplyError(
|
||||
"The supplied preflight receipt digest does not match the stored preflight receipt."
|
||||
)
|
||||
return job, receipt_sha
|
||||
|
||||
|
||||
def _allowed_policy_id(session: Session) -> str | None:
|
||||
policies = session.exec(
|
||||
select(Policy).where(
|
||||
(Policy.action == "deployment.apply") & (Policy.target == _APPLICATION_ID)
|
||||
)
|
||||
).all()
|
||||
if len(policies) != 1:
|
||||
return None
|
||||
policy = policies[0]
|
||||
decision = evaluate_policy(
|
||||
session, action="deployment.apply", target=_APPLICATION_ID, source="manual"
|
||||
)
|
||||
allowed = bool(
|
||||
policy.manual == "allowed"
|
||||
and policy.voice == "blocked"
|
||||
and policy.risk == "high"
|
||||
and decision.get("decision") == "allowed"
|
||||
and decision.get("policy_id") == policy.id
|
||||
)
|
||||
return policy.id if allowed else None
|
||||
|
||||
|
||||
def _attestations_ready(controller: dict[str, Any], profile: ForgeBootstrapApplyV1) -> bool:
|
||||
attestations = controller.get("attestations")
|
||||
if not isinstance(attestations, dict) or set(attestations) != {
|
||||
"push_validation", "image_registry", "oidc", "secret_delivery",
|
||||
}:
|
||||
return False
|
||||
push = attestations.get("push_validation")
|
||||
registry = attestations.get("image_registry")
|
||||
oidc = attestations.get("oidc")
|
||||
secret_delivery = attestations.get("secret_delivery")
|
||||
private_owner = _protected_forge_private_package_owner()
|
||||
return bool(
|
||||
isinstance(push, dict)
|
||||
and set(push) == {"context", "conclusion", "revision"}
|
||||
and push == {
|
||||
"context": "Managed validation / full (push)",
|
||||
"conclusion": "success",
|
||||
"revision": profile.revision,
|
||||
}
|
||||
and isinstance(registry, dict)
|
||||
and private_owner is not None
|
||||
and registry
|
||||
== {
|
||||
"owner": private_owner,
|
||||
"ownerVisibility": "private",
|
||||
"workspaceDigest": profile.workspace_image_digest,
|
||||
"frontdoorDigest": profile.frontdoor_image_digest,
|
||||
"workspacePullable": True,
|
||||
"frontdoorPullable": True,
|
||||
"workspaceSource": _FORGE_SOURCE_URL,
|
||||
"workspaceRevision": profile.revision,
|
||||
"frontdoorSource": _FORGE_SOURCE_URL,
|
||||
"frontdoorRevision": profile.revision,
|
||||
}
|
||||
and forge_oidc_attestation_matches(oidc)
|
||||
and isinstance(secret_delivery, dict)
|
||||
and set(secret_delivery) == {"requiredFilesReady", "metadataSha256"}
|
||||
and secret_delivery.get("requiredFilesReady") is True
|
||||
and isinstance(secret_delivery.get("metadataSha256"), str)
|
||||
and re.fullmatch(r"[0-9a-f]{64}", secret_delivery["metadataSha256"])
|
||||
)
|
||||
|
||||
|
||||
def _protected_forge_private_package_owner() -> str | None:
|
||||
owner = settings.apis.get(_FORGE_PRIVATE_PACKAGE_OWNER_SETTING)
|
||||
if not isinstance(owner, str) or _FORGE_PRIVATE_PACKAGE_OWNER_PATTERN.fullmatch(owner) is None:
|
||||
return None
|
||||
return owner
|
||||
|
||||
|
||||
def forge_oidc_attestation_matches(value: object) -> bool:
|
||||
"""Validate the fixed controller proof against protected AppOps Authentik pins."""
|
||||
if not isinstance(value, dict) or set(value) != _OIDC_ATTESTATION_FIELDS:
|
||||
return False
|
||||
if settings.apis.get(_AUTHENTIK_FORGE_OIDC_BINDING_VALID_SETTING) is not True:
|
||||
return False
|
||||
provider_id = settings.apis.get(_AUTHENTIK_FORGE_OIDC_PROVIDER_ID_SETTING)
|
||||
mapping_ids = {
|
||||
scope: settings.apis.get(setting)
|
||||
for scope, setting in _AUTHENTIK_FORGE_OIDC_SCOPE_MAPPING_SETTINGS.items()
|
||||
}
|
||||
signing_key_id = settings.apis.get(_AUTHENTIK_SIGNING_KEY_ID_SETTING)
|
||||
authorization_flow = settings.apis.get(_AUTHENTIK_AUTHORIZATION_FLOW_SETTING)
|
||||
invalidation_flow = settings.apis.get(_AUTHENTIK_INVALIDATION_FLOW_SETTING)
|
||||
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
if (
|
||||
not isinstance(provider_id, str)
|
||||
or not re.fullmatch(r"[1-9][0-9]{0,19}", provider_id)
|
||||
or any(not isinstance(item, str) or not re.fullmatch(uuid_pattern, item) for item in mapping_ids.values())
|
||||
or len(set(mapping_ids.values())) != len(_AUTHENTIK_FORGE_OIDC_SCOPE_MAPPING_SETTINGS)
|
||||
or not isinstance(signing_key_id, str)
|
||||
or not re.fullmatch(uuid_pattern, signing_key_id)
|
||||
or not isinstance(authorization_flow, str)
|
||||
or not authorization_flow.strip()
|
||||
or not isinstance(invalidation_flow, str)
|
||||
or not invalidation_flow.strip()
|
||||
or value.get("providerId") != provider_id
|
||||
or value.get("scopeMappingIds") != mapping_ids
|
||||
or value.get("signingKeyId") != signing_key_id
|
||||
or value.get("authorizationFlowId") != authorization_flow
|
||||
or value.get("invalidationFlowId") != invalidation_flow
|
||||
):
|
||||
return False
|
||||
application_id = value.get("applicationId")
|
||||
if (
|
||||
not isinstance(application_id, str)
|
||||
or not re.fullmatch(uuid_pattern, application_id)
|
||||
or value.get("providerId") != provider_id
|
||||
):
|
||||
return False
|
||||
fingerprint = value.get("signingKeyFingerprintSha256")
|
||||
if not isinstance(fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", fingerprint):
|
||||
return False
|
||||
jwks_kid = value.get("jwksKid")
|
||||
keypair_kid = value.get("signingKeyKid")
|
||||
return bool(
|
||||
value.get("issuer") == _FORGE_OIDC_ISSUER
|
||||
and value.get("audience") == _FORGE_OIDC_CLIENT_ID
|
||||
and value.get("clientId") == _FORGE_OIDC_CLIENT_ID
|
||||
and value.get("clientType") == "confidential"
|
||||
and value.get("callback") == _FORGE_OIDC_CALLBACK
|
||||
and value.get("clientSecretMatched") is True
|
||||
and isinstance(value.get("authorizationFlowId"), str)
|
||||
and isinstance(value.get("invalidationFlowId"), str)
|
||||
and value.get("roleGroups") == _FORGE_OIDC_GROUPS
|
||||
and value.get("profileMappingName") == _FORGE_OIDC_PROFILE_MAPPING_NAME
|
||||
and isinstance(value.get("scopeMappingExpressionSha256"), dict)
|
||||
and value.get("scopeMappingExpressionSha256") == _FORGE_OIDC_SCOPE_MAPPING_EXPRESSION_SHA256
|
||||
and value.get("standardMappingsManaged") is True
|
||||
and value.get("supportedScopes") == _FORGE_OIDC_SCOPES
|
||||
and value.get("authorizationCodeFlowSupported") is True
|
||||
and value.get("signingKeyType") == "RSA"
|
||||
and value.get("signingKeyPrivateAvailable") is True
|
||||
and value.get("tokenSigningAlgorithm") == "RS256"
|
||||
and _forge_oidc_kid_is_valid(keypair_kid)
|
||||
and jwks_kid == keypair_kid
|
||||
)
|
||||
|
||||
|
||||
def _forge_oidc_kid_is_valid(value: object) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if re.fullmatch(r"[0-9a-f]{32}", value):
|
||||
return True
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{86}", value) is None:
|
||||
return False
|
||||
try:
|
||||
decoded = base64.b64decode(value + "==", altchars=b"-_", validate=True)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
len(decoded) == 64
|
||||
and base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") == value
|
||||
)
|
||||
|
||||
|
||||
def _controller_secret(session: Session) -> str | None:
|
||||
trust = EnrollmentPlane.get_trust_config(session)
|
||||
if (
|
||||
trust is None
|
||||
# The fixed Tower controller shares the existing enrollment identity
|
||||
# with RepoPulse; changing global trust would invalidate that binding.
|
||||
or trust.controller_identity != "unraid-controller"
|
||||
or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT
|
||||
or trust.managed_appdata_backup_acknowledged is not True
|
||||
):
|
||||
return None
|
||||
try:
|
||||
secret = get_secret(session, _SECRET_REF, touch=False)
|
||||
except Exception: # noqa: BLE001 - never expose secret store errors.
|
||||
return None
|
||||
if (
|
||||
not isinstance(secret, str)
|
||||
or len(secret) < 32
|
||||
or not secret.isascii()
|
||||
or not secret.isprintable()
|
||||
):
|
||||
return None
|
||||
return secret
|
||||
|
||||
|
||||
def _response_identity_valid(
|
||||
result: dict[str, Any], *, request_id: str, profile: ForgeBootstrapApplyV1
|
||||
) -> bool:
|
||||
return bool(
|
||||
result.get("apiVersion") == _API_VERSION
|
||||
and result.get("request") == request_id
|
||||
and result.get("repository") == _REPOSITORY
|
||||
and result.get("revision") == profile.revision
|
||||
and result.get("operation") == _OPERATION
|
||||
and result.get("workspaceImageDigest") == profile.workspace_image_digest
|
||||
and result.get("frontdoorImageDigest") == profile.frontdoor_image_digest
|
||||
)
|
||||
|
||||
|
||||
def _classify_controller_result(
|
||||
result: object,
|
||||
*,
|
||||
request_id: str,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
) -> tuple[str, str, str, dict[str, Any]]:
|
||||
"""Return status/stage/summary and strictly projected controller evidence."""
|
||||
if not isinstance(result, dict) or not _response_identity_valid(
|
||||
result, request_id=request_id, profile=profile
|
||||
):
|
||||
return (
|
||||
"outcome_unknown",
|
||||
"controller_response_unverified",
|
||||
_unknown_summary(),
|
||||
{},
|
||||
)
|
||||
status = result.get("status")
|
||||
base_fields = {
|
||||
"apiVersion",
|
||||
"status",
|
||||
"request",
|
||||
"repository",
|
||||
"revision",
|
||||
"operation",
|
||||
"workspaceImageDigest",
|
||||
"frontdoorImageDigest",
|
||||
}
|
||||
identity = {key: result[key] for key in sorted(base_fields)}
|
||||
if status in {"accepted", "running"} and set(result) == base_fields:
|
||||
return (
|
||||
"applying",
|
||||
"controller_accepted" if status == "accepted" else "controller_running",
|
||||
"Forge first install is running on the fixed controller.",
|
||||
identity,
|
||||
)
|
||||
if status == "needs_database_credentials" and set(result) == base_fields:
|
||||
return (
|
||||
"needs_database_credentials",
|
||||
"controller_needs_database_credentials",
|
||||
"Forge first install is paused before migration until fixed database credentials are provisioned.",
|
||||
identity,
|
||||
)
|
||||
if (
|
||||
status == "preflight_changed"
|
||||
and set(result) == base_fields | {"noMutation"}
|
||||
and result.get("noMutation") is True
|
||||
):
|
||||
return (
|
||||
"preflight_changed",
|
||||
"controller_preflight_changed",
|
||||
"Controller rejected changed Forge first-install preflight before mutation.",
|
||||
{**identity, "noMutation": True},
|
||||
)
|
||||
if status == "deployed" and set(result) == base_fields | {"receipt"}:
|
||||
receipt = result.get("receipt")
|
||||
if not isinstance(receipt, dict) or set(receipt) != {
|
||||
"revision",
|
||||
"workspaceImageDigest",
|
||||
"frontdoorImageDigest",
|
||||
"migration",
|
||||
"health",
|
||||
}:
|
||||
return (
|
||||
"outcome_unknown",
|
||||
"controller_receipt_unverified",
|
||||
_unknown_summary(),
|
||||
{},
|
||||
)
|
||||
migration = receipt.get("migration")
|
||||
health = receipt.get("health")
|
||||
if (
|
||||
receipt.get("revision") != profile.revision
|
||||
or receipt.get("workspaceImageDigest") != profile.workspace_image_digest
|
||||
or receipt.get("frontdoorImageDigest") != profile.frontdoor_image_digest
|
||||
or not isinstance(migration, dict)
|
||||
or set(migration) != {"service", "status", "result"}
|
||||
or migration
|
||||
!= {"service": "migrate", "status": "completed", "result": "succeeded"}
|
||||
or not isinstance(health, dict)
|
||||
or set(health) != {"status", "services"}
|
||||
or health.get("status") != "healthy"
|
||||
or health.get("services") != _HEALTHY_SERVICES
|
||||
):
|
||||
return (
|
||||
"outcome_unknown",
|
||||
"controller_receipt_unverified",
|
||||
_unknown_summary(),
|
||||
{},
|
||||
)
|
||||
safe_receipt = {
|
||||
"revision": profile.revision,
|
||||
"workspaceImageDigest": profile.workspace_image_digest,
|
||||
"frontdoorImageDigest": profile.frontdoor_image_digest,
|
||||
"migration": dict(migration),
|
||||
"health": {"status": "healthy", "services": list(_HEALTHY_SERVICES)},
|
||||
}
|
||||
return (
|
||||
"deployed",
|
||||
"controller_deployed",
|
||||
"Forge first install completed with exact migration and health receipts.",
|
||||
{**identity, "receipt": safe_receipt},
|
||||
)
|
||||
if status == "failed" and set(result) == base_fields | {"failure"}:
|
||||
failure = result.get("failure")
|
||||
if (
|
||||
isinstance(failure, dict)
|
||||
and set(failure) == {"code", "preservedData"}
|
||||
and failure.get("code") in _FAILED_CODES
|
||||
and failure.get("preservedData") is True
|
||||
):
|
||||
return (
|
||||
"failed",
|
||||
"controller_failed_preserving_data",
|
||||
"Controller reported a first-install failure and confirmed PostgreSQL data preservation.",
|
||||
{
|
||||
**identity,
|
||||
"failure": {
|
||||
"code": failure["code"],
|
||||
"preservedData": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
return (
|
||||
"outcome_unknown",
|
||||
"controller_response_unverified",
|
||||
_unknown_summary(),
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
def _serialize(job: DeploymentJob, *, replay: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"id": job.id,
|
||||
"applicationId": job.application_id,
|
||||
"repository": job.repository,
|
||||
"revision": job.revision,
|
||||
"status": job.status,
|
||||
"stage": job.stage,
|
||||
"preflight": dict(job.preflight_json or {}),
|
||||
"evidence": dict(job.evidence_json or {}),
|
||||
"result": job.result_summary,
|
||||
"request": job.broker_request_id,
|
||||
"createdAt": job.created_at.isoformat(),
|
||||
"updatedAt": job.updated_at.isoformat(),
|
||||
"completedAt": job.completed_at.isoformat() if job.completed_at else None,
|
||||
"idempotentReplay": replay,
|
||||
}
|
||||
|
||||
|
||||
class ForgeBootstrapApplyService:
|
||||
"""One fixed Forge first-install operation with durable, non-replaying apply."""
|
||||
|
||||
def __init__(self, *, broker: DeploymentBrokerClient | None = None):
|
||||
self.broker = broker or DeploymentBrokerClient()
|
||||
|
||||
@staticmethod
|
||||
def request_id(profile: ForgeBootstrapApplyV1, idempotency_key: str) -> str:
|
||||
normalized = _idempotency_key(idempotency_key)
|
||||
payload = {
|
||||
"repository": _REPOSITORY,
|
||||
"operation": _OPERATION,
|
||||
"profile": profile.model_dump(mode="json"),
|
||||
"idempotencyKey": normalized,
|
||||
}
|
||||
return _sha256(payload)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
session: Session,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
normalized_key = _idempotency_key(idempotency_key)
|
||||
idempotency_digest = hashlib.sha256(
|
||||
f"forge-first-install:{normalized_key}".encode()
|
||||
).hexdigest()
|
||||
profile_json = profile.model_dump(mode="json")
|
||||
plan_sha = _sha256(profile_json)
|
||||
existing = session.exec(
|
||||
select(DeploymentJob).where(
|
||||
DeploymentJob.idempotency_sha256 == idempotency_digest
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
if (
|
||||
existing.application_id != _APPLICATION_ID
|
||||
or existing.repository != _REPOSITORY
|
||||
):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"Idempotency-Key already belongs to a different application or repository."
|
||||
)
|
||||
if existing.plan_sha256 != plan_sha:
|
||||
raise ForgeBootstrapApplyError(
|
||||
"Idempotency-Key was already used for a different Forge first-install profile."
|
||||
)
|
||||
return self._refresh_job(session, existing, replay=True)
|
||||
|
||||
preflight_job, receipt_sha = _preflight_ready(session, profile)
|
||||
if _allowed_policy_id(session) is None:
|
||||
raise ForgeBootstrapApplyError(
|
||||
"An allowed exact-target deployment.apply policy is required for Forge first install."
|
||||
)
|
||||
secret = _controller_secret(session)
|
||||
if secret is None:
|
||||
raise ForgeBootstrapApplyError(
|
||||
"Stored managed-root trust and the fixed Forge deployment secret are required."
|
||||
)
|
||||
|
||||
request_id = self.request_id(profile, normalized_key)
|
||||
job_id = f"forge-install-{idempotency_digest}"
|
||||
now = utc_now()
|
||||
job = DeploymentJob(
|
||||
id=job_id,
|
||||
application_id=_APPLICATION_ID,
|
||||
repository=_REPOSITORY,
|
||||
revision=profile.revision,
|
||||
plan_sha256=plan_sha,
|
||||
idempotency_sha256=idempotency_digest,
|
||||
status="applying",
|
||||
stage="controller_request_pending",
|
||||
plan_json=profile_json,
|
||||
preflight_json={
|
||||
"apiVersion": _PREFLIGHT_API_VERSION,
|
||||
"jobId": preflight_job.id,
|
||||
"sha256": receipt_sha,
|
||||
},
|
||||
evidence_json={"profile": "forge-first-install", "controller": {}},
|
||||
broker_request_id=request_id,
|
||||
result_summary="Forge first install is awaiting the fixed controller response.",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(job)
|
||||
try:
|
||||
# The durable request intent must exist before the only POST attempt.
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raced = session.get(DeploymentJob, job_id)
|
||||
if raced and (
|
||||
raced.application_id != _APPLICATION_ID
|
||||
or raced.repository != _REPOSITORY
|
||||
):
|
||||
raise ForgeBootstrapApplyError(
|
||||
"Idempotency-Key already belongs to a different application or repository."
|
||||
) from None
|
||||
if raced and raced.plan_sha256 == plan_sha:
|
||||
return self._refresh_job(session, raced, replay=True)
|
||||
raise ForgeBootstrapApplyError(
|
||||
"The Forge first-install request could not be durably claimed."
|
||||
) from None
|
||||
|
||||
log_event(
|
||||
session,
|
||||
source="deployment-control-plane",
|
||||
identity="operator",
|
||||
action_id="deployment.apply",
|
||||
target=_APPLICATION_ID,
|
||||
target_type="deployment",
|
||||
risk="high",
|
||||
policy_decision="allowed",
|
||||
reason="Submitted one fixed Forge first-install request after exact preflight validation.",
|
||||
result="submitted",
|
||||
execution_plan={
|
||||
"request": request_id,
|
||||
"planSha256": plan_sha,
|
||||
"preflightSha256": receipt_sha,
|
||||
},
|
||||
redacted_metadata={"profile": "forge-first-install", "jobId": job_id},
|
||||
)
|
||||
try:
|
||||
response = self.broker.forge_bootstrap_apply(
|
||||
profile,
|
||||
idempotency_key=normalized_key,
|
||||
secret=secret,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - durable intent is reconciled with GET only.
|
||||
response = {"status": "unreachable"}
|
||||
return self._record_response(
|
||||
session,
|
||||
job,
|
||||
response,
|
||||
profile,
|
||||
expected_updated_at=job.updated_at,
|
||||
)
|
||||
|
||||
def status(self, session: Session, job_id: str) -> dict[str, Any] | None:
|
||||
if not re.fullmatch(r"forge-install-[0-9a-f]{64}", job_id):
|
||||
return None
|
||||
job = session.get(DeploymentJob, job_id)
|
||||
if (
|
||||
not job
|
||||
or job.application_id != _APPLICATION_ID
|
||||
or job.repository != _REPOSITORY
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", job.broker_request_id or "")
|
||||
):
|
||||
return None
|
||||
return self._refresh_job(session, job)
|
||||
|
||||
def resume(self, session: Session, job_id: str) -> dict[str, Any] | None:
|
||||
"""Explicitly resume only after a signed read proves pre-migration pause."""
|
||||
if not re.fullmatch(r"forge-install-[0-9a-f]{64}", job_id):
|
||||
return None
|
||||
job = session.get(DeploymentJob, job_id)
|
||||
if (
|
||||
not job
|
||||
or job.application_id != _APPLICATION_ID
|
||||
or job.repository != _REPOSITORY
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", job.broker_request_id or "")
|
||||
):
|
||||
return None
|
||||
|
||||
# A caller cannot turn an old pending/unknown record into another POST.
|
||||
# First reconcile with the controller, then proceed only from its exact
|
||||
# safe pre-migration credential state.
|
||||
refreshed = self._refresh_job(session, job)
|
||||
if refreshed.get("status") != "needs_database_credentials":
|
||||
return refreshed
|
||||
job = session.get(DeploymentJob, job_id)
|
||||
if job is None:
|
||||
return refreshed
|
||||
session.refresh(job)
|
||||
try:
|
||||
profile = ForgeBootstrapApplyV1.model_validate(job.plan_json)
|
||||
except ValueError:
|
||||
return refreshed
|
||||
preflight_receipt = dict(job.preflight_json or {})
|
||||
if (
|
||||
set(preflight_receipt) != {"apiVersion", "jobId", "sha256"}
|
||||
or preflight_receipt.get("apiVersion") != _PREFLIGHT_API_VERSION
|
||||
or preflight_receipt.get("jobId") != profile.preflight_job_id
|
||||
or preflight_receipt.get("sha256") != profile.preflight_sha256
|
||||
):
|
||||
return refreshed
|
||||
try:
|
||||
_preflight_ready(session, profile)
|
||||
except ForgeBootstrapApplyError:
|
||||
return refreshed
|
||||
secret = _controller_secret(session)
|
||||
if not secret:
|
||||
return refreshed
|
||||
|
||||
expected_updated_at = job.updated_at
|
||||
resume_pending = "Forge database credentials passed the signed pre-migration gate; resume is pending."
|
||||
claimed_at = utc_now()
|
||||
# Compare-and-swap the observed, signed pre-migration state. Concurrent
|
||||
# callers can both reconcile it, but only one can persist the resume claim.
|
||||
claim = session.exec(
|
||||
update(DeploymentJob)
|
||||
.where(
|
||||
DeploymentJob.id == job.id,
|
||||
DeploymentJob.application_id == _APPLICATION_ID,
|
||||
DeploymentJob.repository == _REPOSITORY,
|
||||
DeploymentJob.status == "needs_database_credentials",
|
||||
DeploymentJob.stage == "controller_needs_database_credentials",
|
||||
DeploymentJob.updated_at == expected_updated_at,
|
||||
)
|
||||
.values(
|
||||
status="applying",
|
||||
stage="resume_request_pending",
|
||||
result_summary=resume_pending,
|
||||
updated_at=claimed_at,
|
||||
)
|
||||
)
|
||||
if claim.rowcount != 1:
|
||||
session.rollback()
|
||||
current = session.get(DeploymentJob, job_id)
|
||||
if current is None:
|
||||
return refreshed
|
||||
session.refresh(current)
|
||||
return _serialize(current)
|
||||
# The one-shot intent is durable before the only resume POST attempt.
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
resume_intent_version = job.updated_at
|
||||
log_event(
|
||||
session,
|
||||
source="deployment-control-plane",
|
||||
identity="operator",
|
||||
action_id="deployment.apply",
|
||||
target=_APPLICATION_ID,
|
||||
target_type="deployment",
|
||||
risk="high",
|
||||
policy_decision="allowed",
|
||||
reason="Requested one explicit Forge first-install resume after signed pre-migration credential status.",
|
||||
result="resume_submitted",
|
||||
execution_plan={"request": job.broker_request_id, "jobId": job.id},
|
||||
redacted_metadata={"profile": "forge-first-install"},
|
||||
)
|
||||
try:
|
||||
response = self.broker.forge_bootstrap_resume(
|
||||
job.broker_request_id or "", secret=secret
|
||||
)
|
||||
except Exception: # noqa: BLE001 - durable resume intent is reconciled with GET only.
|
||||
response = {"status": "unreachable"}
|
||||
return self._record_response(
|
||||
session,
|
||||
job,
|
||||
response,
|
||||
profile,
|
||||
expected_updated_at=resume_intent_version,
|
||||
resume_claimed=True,
|
||||
)
|
||||
|
||||
def _refresh_job(
|
||||
self,
|
||||
session: Session,
|
||||
job: DeploymentJob,
|
||||
*,
|
||||
replay: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if job.status not in _ACTIVE_STATUSES:
|
||||
return _serialize(job, replay=replay)
|
||||
try:
|
||||
profile = ForgeBootstrapApplyV1.model_validate(job.plan_json)
|
||||
except ValueError:
|
||||
return _serialize(job, replay=replay)
|
||||
secret = _controller_secret(session)
|
||||
expected_updated_at = job.updated_at
|
||||
resume_claimed = job.stage.startswith("resume")
|
||||
if not secret:
|
||||
return self._record_response(
|
||||
session,
|
||||
job,
|
||||
{"status": "unreachable"},
|
||||
profile,
|
||||
replay=replay,
|
||||
expected_updated_at=expected_updated_at,
|
||||
resume_claimed=resume_claimed,
|
||||
)
|
||||
try:
|
||||
response = self.broker.forge_bootstrap_status(
|
||||
job.broker_request_id or "", secret=secret
|
||||
)
|
||||
except Exception: # noqa: BLE001 - reconciliation failures remain unknown.
|
||||
response = {"status": "unreachable"}
|
||||
return self._record_response(
|
||||
session,
|
||||
job,
|
||||
response,
|
||||
profile,
|
||||
replay=replay,
|
||||
expected_updated_at=expected_updated_at,
|
||||
resume_claimed=resume_claimed,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record_response(
|
||||
session: Session,
|
||||
job: DeploymentJob,
|
||||
response: object,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
*,
|
||||
replay: bool = False,
|
||||
expected_updated_at: datetime | None = None,
|
||||
resume_claimed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
expected_updated_at = expected_updated_at or job.updated_at
|
||||
status, stage, result, evidence = _classify_controller_result(
|
||||
response,
|
||||
request_id=job.broker_request_id or "",
|
||||
profile=profile,
|
||||
)
|
||||
resume_claimed = resume_claimed or job.stage.startswith("resume")
|
||||
if resume_claimed:
|
||||
if status == "needs_database_credentials":
|
||||
status = "outcome_unknown"
|
||||
stage = "resume_status_unresolved"
|
||||
result = (
|
||||
"Resume was already claimed but the controller still reports its pre-migration pause; "
|
||||
"reconcile by status without submitting another resume."
|
||||
)
|
||||
else:
|
||||
stage = f"resume_{stage}"
|
||||
next_updated_at = utc_now()
|
||||
next_completed_at = (
|
||||
utc_now() if status in _TERMINAL_STATUSES else job.completed_at
|
||||
)
|
||||
values = {
|
||||
"status": status,
|
||||
"stage": stage,
|
||||
"result_summary": result,
|
||||
"evidence_json": {"profile": "forge-first-install", "controller": evidence},
|
||||
"updated_at": next_updated_at,
|
||||
"completed_at": next_completed_at,
|
||||
}
|
||||
statement = update(DeploymentJob).where(
|
||||
DeploymentJob.id == job.id,
|
||||
DeploymentJob.updated_at == expected_updated_at,
|
||||
)
|
||||
if status == "needs_database_credentials" and not resume_claimed:
|
||||
# A status GET started after the local resume claim may still race a
|
||||
# controller that has not processed the POST. It cannot reopen the gate.
|
||||
statement = statement.where(DeploymentJob.stage.not_like("resume%"))
|
||||
result_update = session.exec(statement.values(**values))
|
||||
if result_update.rowcount != 1:
|
||||
session.rollback()
|
||||
current = session.get(DeploymentJob, job.id)
|
||||
if current is None:
|
||||
return _serialize(job, replay=replay)
|
||||
session.refresh(current)
|
||||
return _serialize(current, replay=replay)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
return _serialize(job, replay=replay)
|
||||
@@ -14,6 +14,12 @@ from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1
|
||||
from .audit_logger import log_event
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
from .enrollment_plane import EnrollmentPlane
|
||||
from .forge_bootstrap_apply import (
|
||||
_FORGE_SOURCE_URL,
|
||||
_protected_forge_private_package_owner,
|
||||
canonical_preflight_receipt,
|
||||
forge_oidc_attestation_matches,
|
||||
)
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret, has_secret
|
||||
|
||||
@@ -71,6 +77,21 @@ _STATUS_DETAILS = {
|
||||
"blocked": "A configured required push validation context rejected this exact commit.",
|
||||
"unknown": "Exact-commit push validation is not configured or cannot be verified.",
|
||||
},
|
||||
"image_registry": {
|
||||
"passed": "The fixed private Forge image owner and both immutable image digests are authenticated and pullable.",
|
||||
"blocked": "One or both immutable Forge image digests are unavailable from the registry.",
|
||||
"unknown": "Registry publication or pull authorization could not be verified.",
|
||||
},
|
||||
"oidc": {
|
||||
"passed": "The fixed Forge OIDC provider, application, and role groups are ready.",
|
||||
"blocked": "The Forge OIDC proof is missing, mismatched, or differs from the protected AppOps mapping and signing-key IDs.",
|
||||
"unknown": "The fixed Forge OIDC configuration could not be verified.",
|
||||
},
|
||||
"secret_delivery": {
|
||||
"passed": "The fixed Forge secret files have the required safe metadata.",
|
||||
"blocked": "One or more fixed Forge secret files are missing or unsafe.",
|
||||
"unknown": "The fixed Forge secret-file metadata could not be verified.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -114,10 +135,6 @@ def _checked_controller_result(
|
||||
raw = raw_checks.get(name)
|
||||
if not isinstance(raw, dict) or set(raw) != {"status"} or raw.get("status") not in {"passed", "blocked", "unknown"}:
|
||||
return None
|
||||
# CI, registry access, OIDC and secret delivery are intentionally
|
||||
# unknown until a configured, source-authenticated proof exists.
|
||||
if name in {"image_registry", "oidc", "secret_delivery"} and raw["status"] != "unknown":
|
||||
return None
|
||||
checks.append(_check(name, raw["status"]))
|
||||
statuses = [item["status"] for item in checks]
|
||||
expected_status = "preflight_blocked" if "blocked" in statuses else "preflight_unknown" if "unknown" in statuses else "preflight_ok"
|
||||
@@ -126,7 +143,7 @@ def _checked_controller_result(
|
||||
raw_evidence = result.get("evidence", {})
|
||||
if not isinstance(raw_evidence, dict) or set(raw_evidence) - {
|
||||
"mainRevision", "sourceManifestSha256", "resourceCollisionCount",
|
||||
"workspaceImagePresent", "frontdoorImagePresent",
|
||||
"workspaceImagePresent", "frontdoorImagePresent", "attestations",
|
||||
}:
|
||||
return None
|
||||
evidence: dict[str, Any] = {}
|
||||
@@ -148,6 +165,84 @@ def _checked_controller_result(
|
||||
if not isinstance(value, bool):
|
||||
return None
|
||||
evidence[name] = value
|
||||
attestations = raw_evidence.get("attestations")
|
||||
oidc_rejected = False
|
||||
validated_attestations: dict[str, dict[str, Any]] = {}
|
||||
if attestations is not None:
|
||||
if not isinstance(attestations, dict) or set(attestations) - {
|
||||
"push_validation", "image_registry", "oidc", "secret_delivery",
|
||||
}:
|
||||
return None
|
||||
push = attestations.get("push_validation")
|
||||
if push is not None and (
|
||||
not isinstance(push, dict)
|
||||
or set(push) != {"context", "conclusion", "revision"}
|
||||
or push.get("context") != "Managed validation / full (push)"
|
||||
or push.get("conclusion") != "success"
|
||||
or push.get("revision") != profile.revision
|
||||
):
|
||||
return None
|
||||
if push is not None:
|
||||
validated_attestations["push_validation"] = dict(push)
|
||||
registry = attestations.get("image_registry")
|
||||
private_owner = _protected_forge_private_package_owner()
|
||||
if registry is not None and (
|
||||
not isinstance(registry, dict)
|
||||
or private_owner is None
|
||||
or registry.get("workspacePullable") is not True
|
||||
or registry.get("frontdoorPullable") is not True
|
||||
or registry != {
|
||||
"owner": private_owner,
|
||||
"ownerVisibility": "private",
|
||||
"workspaceDigest": profile.workspace_image_digest,
|
||||
"frontdoorDigest": profile.frontdoor_image_digest,
|
||||
"workspacePullable": True,
|
||||
"frontdoorPullable": True,
|
||||
"workspaceSource": _FORGE_SOURCE_URL,
|
||||
"workspaceRevision": profile.revision,
|
||||
"frontdoorSource": _FORGE_SOURCE_URL,
|
||||
"frontdoorRevision": profile.revision,
|
||||
}
|
||||
):
|
||||
return None
|
||||
if registry is not None:
|
||||
validated_attestations["image_registry"] = dict(registry)
|
||||
oidc = attestations.get("oidc")
|
||||
if oidc is not None:
|
||||
if raw_checks["oidc"]["status"] != "passed":
|
||||
return None
|
||||
if forge_oidc_attestation_matches(oidc):
|
||||
validated_attestations["oidc"] = dict(oidc)
|
||||
else:
|
||||
oidc_rejected = True
|
||||
elif raw_checks["oidc"]["status"] == "passed":
|
||||
oidc_rejected = True
|
||||
secret_delivery = attestations.get("secret_delivery")
|
||||
if secret_delivery is not None and (
|
||||
not isinstance(secret_delivery, dict)
|
||||
or set(secret_delivery) != {"requiredFilesReady", "metadataSha256"}
|
||||
or secret_delivery.get("requiredFilesReady") is not True
|
||||
or not isinstance(secret_delivery.get("metadataSha256"), str)
|
||||
or not re.fullmatch(r"^[0-9a-f]{64}$", secret_delivery["metadataSha256"])
|
||||
):
|
||||
return None
|
||||
if secret_delivery is not None:
|
||||
validated_attestations["secret_delivery"] = dict(secret_delivery)
|
||||
if any(
|
||||
raw_checks[name]["status"] == "passed" and
|
||||
(not isinstance(attestations, dict) or name not in attestations)
|
||||
for name in ("push_validation", "image_registry", "secret_delivery")
|
||||
):
|
||||
return None
|
||||
if raw_checks["oidc"]["status"] == "passed" and "oidc" not in validated_attestations:
|
||||
oidc_rejected = True
|
||||
if oidc_rejected:
|
||||
for check in checks:
|
||||
if check["name"] == "oidc":
|
||||
check.update(_check("oidc", "blocked"))
|
||||
break
|
||||
if validated_attestations:
|
||||
evidence["attestations"] = validated_attestations
|
||||
return checks, evidence
|
||||
|
||||
|
||||
@@ -260,6 +355,7 @@ class ForgeBootstrapPreflightService:
|
||||
},
|
||||
evidence_json={
|
||||
"profile": "forge-production-bootstrap",
|
||||
"policyId": exact[0].id if policy_ready else None,
|
||||
"workspaceImageDigest": profile.workspace_image_digest,
|
||||
"frontdoorImageDigest": profile.frontdoor_image_digest,
|
||||
"controller": controller_evidence,
|
||||
@@ -302,6 +398,7 @@ class ForgeBootstrapPreflightService:
|
||||
"status": job.status,
|
||||
"stage": job.stage,
|
||||
"preflight": job.preflight_json,
|
||||
"preflightSha256": canonical_preflight_receipt(job),
|
||||
"evidence": job.evidence_json,
|
||||
"result": job.result_summary,
|
||||
"brokerRequestId": job.broker_request_id,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,834 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..core.config import settings
|
||||
from ..db.models import Policy
|
||||
from ..schemas.forge_oidc import (
|
||||
FORGE_OIDC_ACTION,
|
||||
FORGE_OIDC_BINDING_SECRET_REF,
|
||||
FORGE_OIDC_CLIENT_ID,
|
||||
FORGE_OIDC_SECRET_REF,
|
||||
FORGE_OIDC_TARGET,
|
||||
ForgeOidcBindingV1,
|
||||
ForgeOidcProvisionResultV1,
|
||||
ForgeOidcScopeMappingIdsV1,
|
||||
)
|
||||
from .audit_logger import log_event
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret, has_secret, secret_storage_status, set_secret
|
||||
|
||||
_SECRET_LOCK = Lock()
|
||||
_PROVISION_LOCK_FILENAME = ".forge-oidc-provision.lock"
|
||||
_PROVISION_LOCK_TIMEOUT_SECONDS = 30.0
|
||||
_PROVISION_LOCK_POLL_INTERVAL_SECONDS = 0.05
|
||||
_FORGE_OIDC_LOCK_ROOT = Path("/run/itworx-appops/forge-oidc-provision")
|
||||
|
||||
|
||||
class _ProvisionLockUnavailable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _is_reparse_point(stat_result: os.stat_result) -> bool:
|
||||
attributes = getattr(stat_result, "st_file_attributes", 0)
|
||||
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
||||
return bool(attributes & reparse_flag)
|
||||
|
||||
|
||||
def _windows_current_user_sid() -> bytes:
|
||||
"""Return the effective Windows token user SID without shelling out."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
||||
advapi32.OpenProcessToken.restype = wintypes.BOOL
|
||||
advapi32.GetTokenInformation.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
]
|
||||
advapi32.GetTokenInformation.restype = wintypes.BOOL
|
||||
advapi32.GetLengthSid.argtypes = [ctypes.c_void_p]
|
||||
advapi32.GetLengthSid.restype = wintypes.DWORD
|
||||
kernel32.GetCurrentProcess.argtypes = []
|
||||
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
token = wintypes.HANDLE()
|
||||
if not advapi32.OpenProcessToken(
|
||||
kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)
|
||||
):
|
||||
raise OSError(ctypes.get_last_error(), "OpenProcessToken failed")
|
||||
try:
|
||||
required = wintypes.DWORD()
|
||||
advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required))
|
||||
if not required.value:
|
||||
raise OSError(ctypes.get_last_error(), "GetTokenInformation failed")
|
||||
token_user = ctypes.create_string_buffer(required.value)
|
||||
if not advapi32.GetTokenInformation(
|
||||
token, 1, token_user, required.value, ctypes.byref(required)
|
||||
):
|
||||
raise OSError(ctypes.get_last_error(), "GetTokenInformation failed")
|
||||
sid = ctypes.cast(token_user, ctypes.POINTER(ctypes.c_void_p)).contents.value
|
||||
sid_length = advapi32.GetLengthSid(ctypes.c_void_p(sid))
|
||||
if not sid or not sid_length:
|
||||
raise OSError("invalid current-user SID")
|
||||
return ctypes.string_at(sid, sid_length)
|
||||
finally:
|
||||
kernel32.CloseHandle(token)
|
||||
|
||||
|
||||
def _windows_private_path(path: Path, *, require_current_owner: bool) -> None:
|
||||
"""Reject Windows paths whose owner/DACL lets an untrusted principal mutate them."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
advapi32.GetNamedSecurityInfoW.argtypes = [
|
||||
wintypes.LPWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
]
|
||||
advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
|
||||
advapi32.GetLengthSid.argtypes = [ctypes.c_void_p]
|
||||
advapi32.GetLengthSid.restype = wintypes.DWORD
|
||||
advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPWSTR, ctypes.POINTER(ctypes.c_void_p)]
|
||||
advapi32.ConvertStringSidToSidW.restype = wintypes.BOOL
|
||||
advapi32.GetAclInformation.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
advapi32.GetAclInformation.restype = wintypes.BOOL
|
||||
advapi32.GetAce.argtypes = [ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
||||
advapi32.GetAce.restype = wintypes.BOOL
|
||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
kernel32.LocalFree.restype = ctypes.c_void_p
|
||||
owner = ctypes.c_void_p()
|
||||
dacl = ctypes.c_void_p()
|
||||
descriptor = ctypes.c_void_p()
|
||||
# SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION
|
||||
result = advapi32.GetNamedSecurityInfoW(
|
||||
str(path),
|
||||
1,
|
||||
0x1 | 0x4,
|
||||
ctypes.byref(owner),
|
||||
None,
|
||||
ctypes.byref(dacl),
|
||||
None,
|
||||
ctypes.byref(descriptor),
|
||||
)
|
||||
if result != 0 or not owner.value or not dacl.value:
|
||||
raise OSError(int(result or ctypes.get_last_error()), "security descriptor unavailable")
|
||||
try:
|
||||
current_sid = _windows_current_user_sid()
|
||||
sid_size = advapi32.GetLengthSid(owner)
|
||||
owner_sid = ctypes.string_at(owner.value, sid_size)
|
||||
if require_current_owner and owner_sid != current_sid:
|
||||
raise OSError("path owner is not the current user")
|
||||
|
||||
# SYSTEM and local Administrators are trusted operating-system principals.
|
||||
trusted_sids = {current_sid}
|
||||
for sid_text in (
|
||||
"S-1-5-18", # LocalSystem
|
||||
"S-1-5-32-544", # Builtin Administrators
|
||||
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", # TrustedInstaller
|
||||
):
|
||||
sid_buffer = ctypes.c_void_p()
|
||||
if not advapi32.ConvertStringSidToSidW(sid_text, ctypes.byref(sid_buffer)):
|
||||
raise OSError(ctypes.get_last_error(), "trusted SID unavailable")
|
||||
try:
|
||||
sid_size = advapi32.GetLengthSid(sid_buffer)
|
||||
trusted_sids.add(ctypes.string_at(sid_buffer.value, sid_size))
|
||||
finally:
|
||||
kernel32.LocalFree(sid_buffer)
|
||||
|
||||
# Reject any explicit or inherited allow ACE outside the trusted set that
|
||||
# can create/replace/delete this path or write to a lock file.
|
||||
class _AclSizeInformation(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("AceCount", wintypes.DWORD),
|
||||
("AclBytesInUse", wintypes.DWORD),
|
||||
("AclBytesFree", wintypes.DWORD),
|
||||
]
|
||||
|
||||
acl_info = _AclSizeInformation()
|
||||
if not advapi32.GetAclInformation(
|
||||
dacl,
|
||||
ctypes.byref(acl_info),
|
||||
ctypes.sizeof(acl_info),
|
||||
2,
|
||||
):
|
||||
raise OSError(ctypes.get_last_error(), "ACL unavailable")
|
||||
|
||||
write_rights = (
|
||||
0x00000002 # FILE_WRITE_DATA / FILE_ADD_FILE
|
||||
| 0x00000004 # FILE_APPEND_DATA / FILE_ADD_SUBDIRECTORY
|
||||
| 0x00000010 # FILE_WRITE_EA
|
||||
| 0x00000040 # FILE_DELETE_CHILD
|
||||
| 0x00000100 # FILE_WRITE_ATTRIBUTES
|
||||
| 0x00010000 # DELETE
|
||||
| 0x00040000 # WRITE_DAC
|
||||
| 0x00080000 # WRITE_OWNER
|
||||
| 0x10000000 # GENERIC_ALL
|
||||
| 0x40000000 # GENERIC_WRITE
|
||||
)
|
||||
for index in range(acl_info.AceCount):
|
||||
ace = ctypes.c_void_p()
|
||||
if not advapi32.GetAce(dacl, index, ctypes.byref(ace)):
|
||||
raise OSError(ctypes.get_last_error(), "ACL entry unavailable")
|
||||
header = ctypes.cast(ace, ctypes.POINTER(ctypes.c_ubyte * 2)).contents
|
||||
ace_type = int(header[0])
|
||||
if ace_type != 0: # ACCESS_ALLOWED_ACE
|
||||
if ace_type not in {1, 2, 3, 6, 7, 8, 10, 12, 13, 14}:
|
||||
# Deny and audit entries do not grant rights. Unknown ACEs
|
||||
# could grant access and therefore make this path untrusted.
|
||||
raise OSError("unsupported ACL entry")
|
||||
continue
|
||||
mask = ctypes.cast(
|
||||
ctypes.c_void_p(ace.value + 4), ctypes.POINTER(wintypes.DWORD)
|
||||
).contents.value
|
||||
sid_address = ace.value + 8
|
||||
sid_size = advapi32.GetLengthSid(ctypes.c_void_p(sid_address))
|
||||
ace_sid = ctypes.string_at(sid_address, sid_size)
|
||||
if mask & write_rights and ace_sid not in trusted_sids:
|
||||
raise OSError("path ACL grants write access to an untrusted principal")
|
||||
finally:
|
||||
if descriptor.value:
|
||||
kernel32.LocalFree(descriptor)
|
||||
|
||||
|
||||
def _lock_root() -> Path:
|
||||
# Linux containers share this private runtime path among their worker
|
||||
# processes. Windows keeps the equivalent lock beside its protected data.
|
||||
return Path(settings.data_dir) if os.name == "nt" else _FORGE_OIDC_LOCK_ROOT
|
||||
|
||||
|
||||
def _validate_lock_data_dir(data_dir: Path) -> None:
|
||||
if not data_dir.is_absolute():
|
||||
raise OSError("lock directory must be absolute")
|
||||
|
||||
# Inspect each existing path component without following symlinks/reparse points.
|
||||
components = [Path(data_dir.anchor)]
|
||||
for part in data_dir.parts[1:]:
|
||||
components.append(components[-1] / part)
|
||||
private_components = {data_dir, data_dir.parent}
|
||||
for index, component in enumerate(components):
|
||||
try:
|
||||
component_stat = os.lstat(component)
|
||||
except FileNotFoundError:
|
||||
if os.name == "nt" or component not in private_components:
|
||||
raise OSError("lock directory path is missing") from None
|
||||
try:
|
||||
os.mkdir(component, 0o700)
|
||||
except FileExistsError:
|
||||
# Another worker may have created the same fixed private child;
|
||||
# validate its metadata below before using it.
|
||||
pass
|
||||
component_stat = os.lstat(component)
|
||||
if _is_reparse_point(component_stat) or not stat.S_ISDIR(component_stat.st_mode):
|
||||
raise OSError("lock directory contains an unsafe path component")
|
||||
is_private_component = component in private_components
|
||||
if os.name == "nt":
|
||||
_windows_private_path(component, require_current_owner=component == data_dir)
|
||||
continue
|
||||
|
||||
effective_uid = os.geteuid()
|
||||
if component_stat.st_uid not in {effective_uid, 0}:
|
||||
raise OSError("lock directory path has an untrusted owner")
|
||||
mode = stat.S_IMODE(component_stat.st_mode)
|
||||
if is_private_component:
|
||||
if component_stat.st_uid != effective_uid or mode != 0o700:
|
||||
raise OSError("lock directory is not private to its owner")
|
||||
elif mode & 0o022 and not (
|
||||
component_stat.st_uid == 0
|
||||
and mode & stat.S_ISVTX
|
||||
and mode & 0o002
|
||||
):
|
||||
# Root-owned sticky directories such as /tmp safely contain private,
|
||||
# owner-checked children; other writable ancestors are not trusted.
|
||||
raise OSError("lock directory has a writable untrusted ancestor")
|
||||
|
||||
|
||||
def _validate_lock_file(lock_fd: int, lock_path: Path) -> None:
|
||||
opened = os.fstat(lock_fd)
|
||||
named = os.lstat(lock_path)
|
||||
if (
|
||||
_is_reparse_point(named)
|
||||
or not stat.S_ISREG(opened.st_mode)
|
||||
or not stat.S_ISREG(named.st_mode)
|
||||
or not os.path.samestat(opened, named)
|
||||
or opened.st_nlink != 1
|
||||
or opened.st_size > 1
|
||||
):
|
||||
raise OSError("lock file is not a private regular file")
|
||||
if os.name == "nt":
|
||||
_windows_private_path(lock_path, require_current_owner=True)
|
||||
return
|
||||
if opened.st_uid != os.geteuid() or stat.S_IMODE(opened.st_mode) & 0o077:
|
||||
raise OSError("lock file is not private to its owner")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _forge_oidc_process_lock() -> Iterator[None]:
|
||||
"""Serialize provisioning across workers in one AppOps runtime."""
|
||||
deadline = time.monotonic() + _PROVISION_LOCK_TIMEOUT_SECONDS
|
||||
thread_locked = _SECRET_LOCK.acquire(
|
||||
timeout=max(0.0, deadline - time.monotonic())
|
||||
)
|
||||
if not thread_locked:
|
||||
raise _ProvisionLockUnavailable from None
|
||||
lock_fd: int | None = None
|
||||
locked = False
|
||||
try:
|
||||
try:
|
||||
lock_root = _lock_root()
|
||||
_validate_lock_data_dir(lock_root)
|
||||
lock_path = lock_root / _PROVISION_LOCK_FILENAME
|
||||
flags = os.O_RDWR
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
if hasattr(os, "O_CLOEXEC"):
|
||||
flags |= os.O_CLOEXEC
|
||||
try:
|
||||
lock_fd = os.open(str(lock_path), flags | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError:
|
||||
lock_fd = os.open(str(lock_path), flags)
|
||||
_validate_lock_file(lock_fd, lock_path)
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
if os.fstat(lock_fd).st_size == 0:
|
||||
os.write(lock_fd, b"\0")
|
||||
_validate_lock_file(lock_fd, lock_path)
|
||||
while True:
|
||||
os.lseek(lock_fd, 0, os.SEEK_SET)
|
||||
try:
|
||||
msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("provision_lock_timeout") from None
|
||||
time.sleep(
|
||||
min(
|
||||
_PROVISION_LOCK_POLL_INTERVAL_SECONDS,
|
||||
max(0.0, deadline - time.monotonic()),
|
||||
)
|
||||
)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("provision_lock_timeout") from None
|
||||
time.sleep(
|
||||
min(
|
||||
_PROVISION_LOCK_POLL_INTERVAL_SECONDS,
|
||||
max(0.0, deadline - time.monotonic()),
|
||||
)
|
||||
)
|
||||
locked = True
|
||||
except Exception: # noqa: BLE001 - all lock setup errors fail closed.
|
||||
# Keep platform-specific path/ACL errors internal and fail closed
|
||||
# with the stable service error emitted by the caller.
|
||||
if lock_fd is not None:
|
||||
try:
|
||||
os.close(lock_fd)
|
||||
except OSError:
|
||||
pass
|
||||
lock_fd = None
|
||||
raise _ProvisionLockUnavailable from None
|
||||
|
||||
yield
|
||||
finally:
|
||||
if lock_fd is not None:
|
||||
if locked:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
os.lseek(lock_fd, 0, os.SEEK_SET)
|
||||
msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
# Closing the descriptor also releases its OS lock.
|
||||
pass
|
||||
try:
|
||||
os.close(lock_fd)
|
||||
except OSError:
|
||||
pass
|
||||
if thread_locked:
|
||||
_SECRET_LOCK.release()
|
||||
|
||||
|
||||
def _new_client_secret() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def _result(
|
||||
*,
|
||||
status: str,
|
||||
message: str,
|
||||
success: bool = False,
|
||||
secret_created: bool = False,
|
||||
application_id: str | None = None,
|
||||
provider_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return ForgeOidcProvisionResultV1(
|
||||
success=success,
|
||||
status=status,
|
||||
message=message,
|
||||
secret_created=secret_created,
|
||||
application_id=application_id,
|
||||
provider_id=provider_id,
|
||||
).model_dump(mode="json", by_alias=True)
|
||||
|
||||
|
||||
def _exact_policy_allowed(session: Session) -> bool:
|
||||
rows = session.exec(
|
||||
select(Policy).where(
|
||||
(Policy.action == FORGE_OIDC_ACTION)
|
||||
& (Policy.target == FORGE_OIDC_TARGET)
|
||||
)
|
||||
).all()
|
||||
if len(rows) != 1:
|
||||
return False
|
||||
policy = rows[0]
|
||||
if policy.manual != "allowed" or policy.voice != "blocked" or policy.risk != "critical":
|
||||
return False
|
||||
decision = evaluate_policy(
|
||||
session,
|
||||
action=FORGE_OIDC_ACTION,
|
||||
target=FORGE_OIDC_TARGET,
|
||||
source="manual",
|
||||
)
|
||||
return (
|
||||
decision.get("decision") == "allowed"
|
||||
and decision.get("scope_decision") == "allowed"
|
||||
and decision.get("policy_id") == policy.id
|
||||
)
|
||||
|
||||
|
||||
def _safe_connector_id(value: Any) -> str | None:
|
||||
candidate = str(value or "")
|
||||
if not candidate or len(candidate) > 128:
|
||||
return None
|
||||
if not all(character.isalnum() or character in "._:-" for character in candidate):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
*,
|
||||
identity: str,
|
||||
decision: str,
|
||||
reason: str,
|
||||
result: str,
|
||||
secret_created: bool = False,
|
||||
) -> None:
|
||||
log_event(
|
||||
session,
|
||||
source="manual",
|
||||
identity=identity,
|
||||
action_id=FORGE_OIDC_ACTION,
|
||||
target=FORGE_OIDC_TARGET,
|
||||
target_type="authentik_application",
|
||||
risk="critical",
|
||||
policy_decision=decision,
|
||||
reason=reason,
|
||||
result=result,
|
||||
redacted_metadata={
|
||||
"clientId": FORGE_OIDC_CLIENT_ID,
|
||||
"secretRef": FORGE_OIDC_SECRET_REF,
|
||||
"secretCreated": secret_created,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _blocked(
|
||||
session: Session,
|
||||
*,
|
||||
identity: str,
|
||||
message: str,
|
||||
status: str = "blocked",
|
||||
decision: str = "blocked",
|
||||
secret_created: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
_audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision=decision,
|
||||
reason=message,
|
||||
result=status,
|
||||
secret_created=secret_created,
|
||||
)
|
||||
return _result(status=status, message=message, secret_created=secret_created)
|
||||
|
||||
|
||||
def _existing_secret(session: Session) -> str | None:
|
||||
if not has_secret(session, FORGE_OIDC_SECRET_REF):
|
||||
return None
|
||||
return get_secret(session, FORGE_OIDC_SECRET_REF, touch=False)
|
||||
|
||||
|
||||
def _persist_forge_oidc_binding(
|
||||
session: Session,
|
||||
*,
|
||||
scope_mapping_ids: Any,
|
||||
provider_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Persist one validated mapping tuple while preserving a known provider ID."""
|
||||
from .setup_service import _apply_forge_oidc_binding_to_runtime
|
||||
|
||||
try:
|
||||
mappings = ForgeOidcScopeMappingIdsV1.model_validate(scope_mapping_ids)
|
||||
except Exception: # noqa: BLE001 - invalid binding data is never serialized.
|
||||
return False
|
||||
|
||||
binding_exists = has_secret(session, FORGE_OIDC_BINDING_SECRET_REF)
|
||||
binding_valid = _apply_forge_oidc_binding_to_runtime(session)
|
||||
if binding_exists and not binding_valid:
|
||||
return False
|
||||
|
||||
current_ids = {
|
||||
"openid": settings.apis.get("AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING"),
|
||||
"email": settings.apis.get("AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING"),
|
||||
"profile": settings.apis.get("AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING"),
|
||||
}
|
||||
new_ids = {key: str(getattr(mappings, key)) for key in ("openid", "email", "profile")}
|
||||
if binding_valid and current_ids != new_ids:
|
||||
return False
|
||||
|
||||
current_provider_id = settings.apis.get("AUTHENTIK_FORGE_OIDC_PROVIDER_ID") if binding_valid else None
|
||||
if current_provider_id is not None and provider_id is not None and current_provider_id != provider_id:
|
||||
return False
|
||||
selected_provider_id = provider_id if provider_id is not None else current_provider_id
|
||||
try:
|
||||
binding = ForgeOidcBindingV1(
|
||||
version=1,
|
||||
providerId=selected_provider_id,
|
||||
scopeMappingIds=new_ids,
|
||||
)
|
||||
set_secret(
|
||||
session,
|
||||
FORGE_OIDC_BINDING_SECRET_REF,
|
||||
json.dumps(binding.model_dump(mode="json", by_alias=True), separators=(",", ":"), sort_keys=True),
|
||||
used_by=["authentik:itworx-forge"],
|
||||
metadata={"managedBy": "appops-forge-oidc-binding/v1"},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - storage and serialization errors stay private.
|
||||
session.rollback()
|
||||
return False
|
||||
return _apply_forge_oidc_binding_to_runtime(session)
|
||||
|
||||
|
||||
def _connector_outcome(
|
||||
session: Session,
|
||||
*,
|
||||
identity: str,
|
||||
outcome: Any,
|
||||
secret_created: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(outcome, dict):
|
||||
status, message = "failed", "forge_oidc_connector_result_invalid"
|
||||
success = False
|
||||
else:
|
||||
raw_status = outcome.get("status")
|
||||
mappings = {
|
||||
"created": ("created", "forge_oidc_created", True),
|
||||
"already_configured": ("already_configured", "forge_oidc_already_configured", True),
|
||||
"blocked": ("blocked", "forge_oidc_connector_blocked", False),
|
||||
"conflict": ("conflict", "forge_oidc_resource_conflict", False),
|
||||
"failed": ("failed", "forge_oidc_provisioning_failed", False),
|
||||
"partial_failure": ("partial_failure", "forge_oidc_partial_failure", False),
|
||||
}
|
||||
status, message, success = mappings.get(
|
||||
raw_status,
|
||||
("failed", "forge_oidc_connector_result_invalid", False),
|
||||
)
|
||||
if outcome.get("success") is not True:
|
||||
success = False
|
||||
elif status not in {"created", "already_configured"}:
|
||||
status, message, success = "failed", "forge_oidc_connector_result_invalid", False
|
||||
|
||||
application_id = _safe_connector_id(outcome.get("applicationId")) if isinstance(outcome, dict) else None
|
||||
provider_id = _safe_connector_id(outcome.get("providerId")) if isinstance(outcome, dict) else None
|
||||
audit_result = "success" if success else status
|
||||
_audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="allowed",
|
||||
reason=message,
|
||||
result=audit_result,
|
||||
secret_created=secret_created,
|
||||
)
|
||||
return _result(
|
||||
status=status,
|
||||
message=message,
|
||||
success=success,
|
||||
secret_created=secret_created,
|
||||
application_id=application_id,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
|
||||
|
||||
def provision_forge_oidc(
|
||||
session: Session,
|
||||
*,
|
||||
actor_identity: str,
|
||||
authentik: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Provision only the fixed Forge OIDC client after exact-target governance."""
|
||||
identity = actor_identity.strip() if isinstance(actor_identity, str) else ""
|
||||
if not identity:
|
||||
return _blocked(
|
||||
session,
|
||||
identity="system",
|
||||
message="forge_oidc_operator_identity_required",
|
||||
)
|
||||
|
||||
try:
|
||||
with _forge_oidc_process_lock():
|
||||
# Re-evaluate governance after entering the runtime lock so concurrent
|
||||
# Forge provisioning attempts cannot interleave preflight and writes.
|
||||
if not _exact_policy_allowed(session):
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_exact_target_policy_required",
|
||||
)
|
||||
if secret_storage_status().get("productionReady") is not True:
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_secret_store_not_production_ready",
|
||||
)
|
||||
return _provision_forge_oidc_locked(
|
||||
session,
|
||||
identity=identity,
|
||||
authentik=authentik,
|
||||
)
|
||||
except _ProvisionLockUnavailable:
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_provision_lock_unavailable",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
|
||||
def _provision_forge_oidc_locked(
|
||||
session: Session,
|
||||
*,
|
||||
identity: str,
|
||||
authentik: Any | None,
|
||||
) -> dict[str, Any]:
|
||||
if authentik is None:
|
||||
try:
|
||||
from .setup_service import apply_stored_connector_settings
|
||||
|
||||
apply_stored_connector_settings(session, {"authentik"})
|
||||
except Exception: # noqa: BLE001 - never expose connector or stored-secret errors.
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_connector_configuration_unavailable",
|
||||
status="failed",
|
||||
decision="allowed",
|
||||
)
|
||||
connector = authentik or AuthentikConnector()
|
||||
|
||||
# A present but unreadable or tampered binding must never be repaired from
|
||||
# live discovery: its IDs are protected state, not caller-selected inputs.
|
||||
from .setup_service import _apply_forge_oidc_binding_to_runtime
|
||||
|
||||
binding_exists = has_secret(session, FORGE_OIDC_BINDING_SECRET_REF)
|
||||
binding_valid = _apply_forge_oidc_binding_to_runtime(session)
|
||||
if binding_exists and not binding_valid:
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_binding_invalid",
|
||||
status="conflict",
|
||||
decision="allowed",
|
||||
)
|
||||
|
||||
secret_created = False
|
||||
client_secret = _existing_secret(session)
|
||||
if client_secret is None and has_secret(session, FORGE_OIDC_SECRET_REF):
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_existing_client_secret_unavailable",
|
||||
status="conflict",
|
||||
decision="allowed",
|
||||
)
|
||||
if settings.apis.get("AUTHENTIK_FORGE_OIDC_PROVIDER_ID") is not None and client_secret is None:
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_existing_client_secret_missing",
|
||||
status="conflict",
|
||||
decision="allowed",
|
||||
)
|
||||
|
||||
try:
|
||||
prerequisites = connector.ensure_forge_oidc_prerequisites(
|
||||
expected_client_secret=client_secret,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - remote connector errors are returned only as a safe status.
|
||||
prerequisites = {"success": False, "status": "failed"}
|
||||
if not isinstance(prerequisites, dict) or prerequisites.get("success") is not True:
|
||||
return _connector_outcome(
|
||||
session,
|
||||
identity=identity,
|
||||
outcome=prerequisites,
|
||||
secret_created=False,
|
||||
)
|
||||
|
||||
mapping_ids = prerequisites.get("scopeMappingIds")
|
||||
try:
|
||||
validated_mapping_ids = ForgeOidcScopeMappingIdsV1.model_validate(mapping_ids)
|
||||
except Exception: # noqa: BLE001 - connector output is treated as untrusted.
|
||||
return _connector_outcome(
|
||||
session,
|
||||
identity=identity,
|
||||
outcome={"success": False, "status": "conflict", "message": "forge_oidc_scope_mapping_id_invalid"},
|
||||
secret_created=False,
|
||||
)
|
||||
serialized_mapping_ids = {
|
||||
key: str(getattr(validated_mapping_ids, key))
|
||||
for key in ("openid", "email", "profile")
|
||||
}
|
||||
if not _persist_forge_oidc_binding(session, scope_mapping_ids=serialized_mapping_ids):
|
||||
return _connector_outcome(
|
||||
session,
|
||||
identity=identity,
|
||||
outcome={
|
||||
"success": False,
|
||||
"status": "partial_failure",
|
||||
"message": "forge_oidc_binding_persist_failed",
|
||||
},
|
||||
secret_created=False,
|
||||
)
|
||||
|
||||
try:
|
||||
preflight = connector.inspect_forge_oidc_application(
|
||||
expected_client_secret=client_secret,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - remote connector errors are returned only as a safe status.
|
||||
preflight = {"success": False, "status": "failed"}
|
||||
if not isinstance(preflight, dict) or preflight.get("success") is not True:
|
||||
return _connector_outcome(
|
||||
session,
|
||||
identity=identity,
|
||||
outcome=preflight,
|
||||
secret_created=False,
|
||||
)
|
||||
if preflight.get("status") not in {"ready", "provider_orphan", "already_configured"}:
|
||||
return _connector_outcome(
|
||||
session,
|
||||
identity=identity,
|
||||
outcome={"success": False, "status": "failed"},
|
||||
secret_created=False,
|
||||
)
|
||||
|
||||
if preflight.get("status") != "ready" and client_secret is None:
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_existing_client_secret_missing",
|
||||
status="conflict",
|
||||
decision="allowed",
|
||||
)
|
||||
if client_secret is None:
|
||||
client_secret = _new_client_secret()
|
||||
if not 32 <= len(client_secret) <= 255 or not client_secret.isascii() or not client_secret.isprintable():
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_client_secret_generation_failed",
|
||||
status="failed",
|
||||
decision="allowed",
|
||||
)
|
||||
try:
|
||||
set_secret(
|
||||
session,
|
||||
FORGE_OIDC_SECRET_REF,
|
||||
client_secret,
|
||||
used_by=["authentik:itworx-forge"],
|
||||
metadata={"managedBy": "appops-forge-oidc/v1"},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - secret-store errors are intentionally sanitized.
|
||||
session.rollback()
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_client_secret_store_failed",
|
||||
status="failed",
|
||||
decision="allowed",
|
||||
)
|
||||
secret_created = True
|
||||
elif (
|
||||
not isinstance(client_secret, str)
|
||||
or not 32 <= len(client_secret) <= 255
|
||||
or not client_secret.isascii()
|
||||
or not client_secret.isprintable()
|
||||
):
|
||||
return _blocked(
|
||||
session,
|
||||
identity=identity,
|
||||
message="forge_oidc_existing_client_secret_unavailable",
|
||||
status="conflict",
|
||||
decision="allowed",
|
||||
)
|
||||
|
||||
try:
|
||||
outcome = connector.ensure_forge_oidc_application(
|
||||
client_secret=client_secret,
|
||||
persist_provider_id=lambda provider_id: _persist_forge_oidc_binding(
|
||||
session,
|
||||
scope_mapping_ids=serialized_mapping_ids,
|
||||
provider_id=provider_id,
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - no connector response/body crosses the route boundary.
|
||||
outcome = {"success": False, "status": "failed"}
|
||||
|
||||
return _connector_outcome(
|
||||
session,
|
||||
identity=identity,
|
||||
outcome=outcome,
|
||||
secret_created=secret_created,
|
||||
)
|
||||
@@ -0,0 +1,599 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import RFC_4122, UUID
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..core.config import settings
|
||||
from ..db.models import AppSetting, Policy
|
||||
from ..schemas.enrollment import MANAGED_DOCKER_APPDATA_ROOT
|
||||
from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1
|
||||
from ..schemas.forge_oidc import FORGE_OIDC_ISSUER, FORGE_OIDC_LAUNCH_URL
|
||||
from ..schemas.forge_secret_delivery import (
|
||||
ForgeSecretDeliveryControllerArtifactV1,
|
||||
ForgeSecretDeliveryResultV1,
|
||||
)
|
||||
from .audit_logger import log_event
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
from .enrollment_plane import EnrollmentPlane
|
||||
from .forge_oidc_provisioning import _forge_oidc_process_lock
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret, has_secret, secret_storage_status
|
||||
|
||||
_ACTION = "forge.secrets.prepare"
|
||||
_TARGET = "itworx-forge"
|
||||
_BROKER_SECRET_REF = "FORGE_DEPLOYMENT_BROKER"
|
||||
_FORGE_OIDC_BINDING_REF = "AUTHENTIK_FORGE_OIDC_BINDING"
|
||||
_FORGE_OIDC_CLIENT_SECRET_REF = "ITWORX_FORGE_OIDC_CLIENT_SECRET"
|
||||
_FORGE_READER_BINDING_REF = "AUTHENTIK_FORGE_READER_BINDING"
|
||||
_FORGE_READER_TOKEN_REF = "ITWORX_FORGE_AUTHENTIK_READ_TOKEN"
|
||||
_FLOW = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
|
||||
_PROVIDER_ID = re.compile(r"[1-9][0-9]{0,9}\Z")
|
||||
_UUID = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z"
|
||||
)
|
||||
_IDEMPOTENCY = re.compile(r"[A-Za-z0-9._:-]{8,128}\Z")
|
||||
_CONFIG_KEYS = {
|
||||
"schema",
|
||||
"issuer",
|
||||
"audience",
|
||||
"client_id",
|
||||
"authentik_api_url",
|
||||
"launch_url",
|
||||
"authorization_flow",
|
||||
"invalidation_flow",
|
||||
"authentik_openid_mapping_id",
|
||||
"authentik_email_mapping_id",
|
||||
"authentik_profile_mapping_id",
|
||||
"authentik_provider_id",
|
||||
"authentik_signing_key_id",
|
||||
"private_package_owner",
|
||||
}
|
||||
_RESPONSE_KEYS = {
|
||||
"apiVersion",
|
||||
"target",
|
||||
"status",
|
||||
"profile",
|
||||
"controllerArtifact",
|
||||
"secretFilesReady",
|
||||
"masterKeyBackupVerified",
|
||||
"applyPreflight",
|
||||
}
|
||||
_ARTIFACT_KEYS = {
|
||||
"controllerVersion",
|
||||
"sourceRepository",
|
||||
"sourceRevision",
|
||||
"sourceTree",
|
||||
"artifactSha256",
|
||||
}
|
||||
|
||||
|
||||
def _exact_secret_prepare_policy_allowed(session: Session) -> str | None:
|
||||
rows = session.exec(select(Policy).where(Policy.action == _ACTION)).all()
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
policy = rows[0]
|
||||
if (
|
||||
policy.target != _TARGET
|
||||
or policy.manual != "allowed"
|
||||
or policy.voice != "blocked"
|
||||
or policy.risk != "critical"
|
||||
):
|
||||
return None
|
||||
decision = evaluate_policy(
|
||||
session,
|
||||
action=_ACTION,
|
||||
target=_TARGET,
|
||||
source="manual",
|
||||
)
|
||||
if (
|
||||
decision.get("decision") != "allowed"
|
||||
or decision.get("scope_decision") != "allowed"
|
||||
or decision.get("policy_id") != policy.id
|
||||
):
|
||||
return None
|
||||
return policy.id
|
||||
|
||||
|
||||
def _secret_store_ready() -> bool:
|
||||
try:
|
||||
status = secret_storage_status()
|
||||
except Exception: # noqa: BLE001 - store diagnostics never enter delivery errors.
|
||||
return False
|
||||
return isinstance(status, dict) and status.get("productionReady") is True
|
||||
|
||||
|
||||
def _canonical_uuid(value: Any) -> bool:
|
||||
if not isinstance(value, str) or _UUID.fullmatch(value) is None:
|
||||
return False
|
||||
try:
|
||||
parsed = UUID(value)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return False
|
||||
return str(parsed) == value and parsed.variant == RFC_4122 and parsed.version in range(1, 9)
|
||||
|
||||
|
||||
def _protected_config(context: dict[str, Any]) -> bytes | None:
|
||||
if not isinstance(context, dict) or set(context) != {
|
||||
"applicationId", "providerId", "scopeMappingIds", "groupIds", "signingKeyId"
|
||||
}:
|
||||
return None
|
||||
provider_id = context.get("providerId")
|
||||
signing_key_id = context.get("signingKeyId")
|
||||
mappings = context.get("scopeMappingIds")
|
||||
if (
|
||||
_PROVIDER_ID.fullmatch(provider_id or "") is None
|
||||
or not _canonical_uuid(signing_key_id)
|
||||
or not isinstance(mappings, dict)
|
||||
or set(mappings) != {"openid", "email", "profile"}
|
||||
or any(not _canonical_uuid(item) for item in mappings.values())
|
||||
or len(set(mappings.values())) != 3
|
||||
):
|
||||
return None
|
||||
if not _canonical_uuid(context.get("applicationId")):
|
||||
return None
|
||||
owner = _protected_forge_private_package_owner()
|
||||
if owner is None:
|
||||
return None
|
||||
authorization_flow = settings.apis.get("AUTHENTIK_OIDC_AUTHORIZATION_FLOW")
|
||||
invalidation_flow = settings.apis.get("AUTHENTIK_OIDC_INVALIDATION_FLOW")
|
||||
if (
|
||||
not isinstance(authorization_flow, str)
|
||||
or _FLOW.fullmatch(authorization_flow) is None
|
||||
or not isinstance(invalidation_flow, str)
|
||||
or _FLOW.fullmatch(invalidation_flow) is None
|
||||
):
|
||||
return None
|
||||
values = {
|
||||
"schema": "itworx.forge-first-install-config/v4",
|
||||
"issuer": FORGE_OIDC_ISSUER,
|
||||
"audience": "itworx-forge",
|
||||
"client_id": "itworx-forge",
|
||||
"authentik_api_url": "https://auth.example-host.com",
|
||||
"launch_url": FORGE_OIDC_LAUNCH_URL,
|
||||
"authorization_flow": authorization_flow,
|
||||
"invalidation_flow": invalidation_flow,
|
||||
"authentik_openid_mapping_id": mappings["openid"],
|
||||
"authentik_email_mapping_id": mappings["email"],
|
||||
"authentik_profile_mapping_id": mappings["profile"],
|
||||
"authentik_provider_id": provider_id,
|
||||
"authentik_signing_key_id": signing_key_id,
|
||||
"private_package_owner": owner,
|
||||
}
|
||||
if set(values) != _CONFIG_KEYS or any(
|
||||
not isinstance(value, str) or not value or len(value) > 512
|
||||
for value in values.values()
|
||||
):
|
||||
return None
|
||||
encoded = json.dumps(values, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return encoded if len(encoded) <= 8192 else None
|
||||
|
||||
|
||||
def _checked_controller_response(
|
||||
value: object,
|
||||
*,
|
||||
profile: ForgeBootstrapPreflightV1,
|
||||
) -> ForgeSecretDeliveryControllerArtifactV1 | None:
|
||||
if not isinstance(value, dict) or set(value) != _RESPONSE_KEYS:
|
||||
return None
|
||||
if (
|
||||
value.get("apiVersion") != "itworx.forge-secret-delivery/v1"
|
||||
or value.get("target") != _TARGET
|
||||
or value.get("status") != "bundle_ready"
|
||||
or value.get("profile") != profile.model_dump(mode="json")
|
||||
or type(value.get("secretFilesReady")) is not int
|
||||
or value.get("secretFilesReady") != 7
|
||||
or value.get("masterKeyBackupVerified") is not True
|
||||
or value.get("applyPreflight") != "not_evaluated"
|
||||
):
|
||||
return None
|
||||
artifact = value.get("controllerArtifact")
|
||||
if not isinstance(artifact, dict) or set(artifact) != _ARTIFACT_KEYS:
|
||||
return None
|
||||
try:
|
||||
return ForgeSecretDeliveryControllerArtifactV1.model_validate(artifact)
|
||||
except Exception: # noqa: BLE001 - controller response data is untrusted.
|
||||
return None
|
||||
|
||||
|
||||
def _result(
|
||||
*,
|
||||
profile: ForgeBootstrapPreflightV1,
|
||||
status: str,
|
||||
message: str,
|
||||
artifact: ForgeSecretDeliveryControllerArtifactV1 | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ready = status == "bundle_ready"
|
||||
payload = ForgeSecretDeliveryResultV1(
|
||||
success=ready,
|
||||
status=status,
|
||||
message=message,
|
||||
target=_TARGET,
|
||||
revision=profile.revision,
|
||||
controllerArtifact=artifact,
|
||||
secretFilesReady=7 if ready else None,
|
||||
masterKeyBackupVerified=True if ready else None,
|
||||
applyPreflight="not_evaluated" if ready else None,
|
||||
)
|
||||
return payload.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
*,
|
||||
identity: str,
|
||||
decision: str,
|
||||
result: dict[str, Any],
|
||||
policy_id: str | None,
|
||||
idempotency_key: str,
|
||||
) -> bool:
|
||||
try:
|
||||
artifact = result.get("controllerArtifact")
|
||||
log_event(
|
||||
session,
|
||||
source="manual",
|
||||
identity=identity,
|
||||
action_id=_ACTION,
|
||||
target=_TARGET,
|
||||
target_type="forge_secret_delivery",
|
||||
risk="critical",
|
||||
policy_decision=decision,
|
||||
reason=result["message"],
|
||||
result=result["status"],
|
||||
after_state={
|
||||
"status": result["status"],
|
||||
"revision": result["revision"],
|
||||
"secretFilesReady": result.get("secretFilesReady"),
|
||||
"masterKeyBackupVerified": result.get("masterKeyBackupVerified"),
|
||||
"applyPreflight": result.get("applyPreflight"),
|
||||
"controllerArtifact": artifact,
|
||||
},
|
||||
redacted_metadata={
|
||||
"policyId": policy_id,
|
||||
"revision": result["revision"],
|
||||
"idempotencyKeySha256": hashlib.sha256(idempotency_key.encode("ascii")).hexdigest(),
|
||||
"secretRefs": [
|
||||
_BROKER_SECRET_REF,
|
||||
_FORGE_OIDC_CLIENT_SECRET_REF,
|
||||
_FORGE_READER_TOKEN_REF,
|
||||
],
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - an audit exception may carry held secrets.
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001 - keep DB error detail out of the response.
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _protected_forge_private_package_owner() -> str | None:
|
||||
from .forge_bootstrap_apply import (
|
||||
_protected_forge_private_package_owner as protected_owner,
|
||||
)
|
||||
|
||||
return protected_owner()
|
||||
|
||||
|
||||
def _fixed_authentik_connector(session: Session) -> AuthentikConnector | None:
|
||||
"""Use only already-loaded, fixed Authentik runtime configuration.
|
||||
|
||||
The generic setup loader also migrates legacy plaintext connector secrets.
|
||||
Secret delivery must not trigger that write as a side effect, so this path
|
||||
verifies the existing runtime against protected/read-only inputs instead.
|
||||
"""
|
||||
try:
|
||||
legacy_secret = session.get(AppSetting, "connector:authentik:secret")
|
||||
if legacy_secret is not None:
|
||||
if legacy_secret.is_secret is not True or not isinstance(legacy_secret.value_json, dict):
|
||||
return None
|
||||
legacy_value = legacy_secret.value_json.get("value")
|
||||
if isinstance(legacy_value, str) and legacy_value.strip():
|
||||
return None
|
||||
if legacy_secret.value_json not in (
|
||||
{},
|
||||
{"secretRef": "AUTHENTIK_TOKEN", "hasSecret": True},
|
||||
{"secretRef": "AUTHENTIK_TOKEN", "hasSecret": True, "migrated": True},
|
||||
):
|
||||
return None
|
||||
|
||||
connector_config = session.get(AppSetting, "connector:authentik:config")
|
||||
if connector_config is not None:
|
||||
if connector_config.is_secret is not False or not isinstance(connector_config.value_json, dict):
|
||||
return None
|
||||
if not set(connector_config.value_json).issubset(
|
||||
{"url", "verifyTls", "outpostUrl", "expectEmbeddedOutpost"}
|
||||
):
|
||||
return None
|
||||
|
||||
connector = AuthentikConnector()
|
||||
config = connector._config()
|
||||
if (
|
||||
config is None
|
||||
or config.url.rstrip("/") != "https://auth.example-host.com"
|
||||
or config.verify_tls is not True
|
||||
or not isinstance(config.token, str)
|
||||
or not config.token
|
||||
):
|
||||
return None
|
||||
|
||||
stored_admin_token = get_secret(session, "AUTHENTIK_TOKEN", touch=False)
|
||||
if not isinstance(stored_admin_token, str) or not stored_admin_token:
|
||||
return None
|
||||
normalized = stored_admin_token.strip()
|
||||
if normalized.lower().startswith("bearer "):
|
||||
normalized = normalized[7:].strip()
|
||||
if not hmac.compare_digest(normalized, config.token):
|
||||
return None
|
||||
|
||||
if connector_config is not None:
|
||||
stored_config = connector_config.value_json
|
||||
runtime_values = {
|
||||
"url": config.url,
|
||||
"verifyTls": config.verify_tls,
|
||||
"outpostUrl": config.outpost_url,
|
||||
"expectEmbeddedOutpost": config.expect_embedded_outpost,
|
||||
}
|
||||
if any(stored_config.get(key) != runtime_values[key] for key in stored_config):
|
||||
return None
|
||||
return connector
|
||||
except Exception: # noqa: BLE001 - config/store failures remain a fixed denial.
|
||||
return None
|
||||
|
||||
|
||||
def prepare_forge_secret_delivery(
|
||||
session: Session,
|
||||
*,
|
||||
profile: ForgeBootstrapPreflightV1,
|
||||
idempotency_key: str,
|
||||
actor_identity: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Prepare only the fixed Forge credential bundle through the trusted broker."""
|
||||
identity = actor_identity.strip() if isinstance(actor_identity, str) else ""
|
||||
request_key = idempotency_key.strip() if isinstance(idempotency_key, str) else ""
|
||||
if not identity:
|
||||
return _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_operator_identity_required",
|
||||
)
|
||||
if _IDEMPOTENCY.fullmatch(request_key) is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_idempotency_key_invalid",
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="blocked",
|
||||
result=outcome,
|
||||
policy_id=None,
|
||||
idempotency_key="invalid-key",
|
||||
)
|
||||
return outcome
|
||||
|
||||
policy_id = _exact_secret_prepare_policy_allowed(session)
|
||||
if policy_id is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_exact_target_policy_required",
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="blocked",
|
||||
result=outcome,
|
||||
policy_id=None,
|
||||
idempotency_key=request_key,
|
||||
)
|
||||
return outcome
|
||||
|
||||
if not _secret_store_ready():
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_store_not_production_ready",
|
||||
)
|
||||
audit_ok = _audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="allowed",
|
||||
result=outcome,
|
||||
policy_id=policy_id,
|
||||
idempotency_key=request_key,
|
||||
)
|
||||
return outcome if audit_ok else _result(
|
||||
profile=profile, status="failed", message="forge_secret_audit_persist_failed"
|
||||
)
|
||||
|
||||
attempted_delivery = False
|
||||
try:
|
||||
with _forge_oidc_process_lock():
|
||||
if _exact_secret_prepare_policy_allowed(session) != policy_id:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_exact_target_policy_required",
|
||||
)
|
||||
elif not _secret_store_ready():
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_store_not_production_ready",
|
||||
)
|
||||
else:
|
||||
trust = EnrollmentPlane.get_trust_config(session)
|
||||
if (
|
||||
trust is None
|
||||
or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT
|
||||
or trust.managed_appdata_backup_acknowledged is not True
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_controller_trust_required",
|
||||
)
|
||||
elif not all(
|
||||
has_secret(session, reference)
|
||||
for reference in (
|
||||
_FORGE_OIDC_BINDING_REF,
|
||||
_FORGE_OIDC_CLIENT_SECRET_REF,
|
||||
_FORGE_READER_BINDING_REF,
|
||||
_FORGE_READER_TOKEN_REF,
|
||||
)
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_verified_reader_required",
|
||||
)
|
||||
else:
|
||||
from .forge_bootstrap_apply import _controller_secret
|
||||
from .forge_controller_reader import (
|
||||
_ReaderFailure,
|
||||
_verify_forge_delivery_inputs,
|
||||
)
|
||||
|
||||
broker_secret = _controller_secret(session)
|
||||
if not isinstance(broker_secret, str) or not broker_secret:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_controller_trust_required",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
connector = _fixed_authentik_connector(session)
|
||||
if connector is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_authentik_configuration_required",
|
||||
)
|
||||
connector = None
|
||||
if connector is None:
|
||||
verified = None
|
||||
else:
|
||||
verified = _verify_forge_delivery_inputs(session, connector)
|
||||
if verified is None:
|
||||
pass
|
||||
else:
|
||||
config_bytes = _protected_config(verified.context)
|
||||
if config_bytes is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="conflict",
|
||||
message="forge_secret_protected_config_invalid",
|
||||
)
|
||||
elif (
|
||||
verified.oidc_client_secret in config_bytes
|
||||
or verified.authentik_read_token in config_bytes
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="conflict",
|
||||
message="forge_secret_config_contains_secret",
|
||||
)
|
||||
else:
|
||||
attempted_delivery = True
|
||||
broker_result = DeploymentBrokerClient().forge_secret_delivery(
|
||||
profile,
|
||||
config_bytes=config_bytes,
|
||||
oidc_client_secret=verified.oidc_client_secret,
|
||||
authentik_read_token=verified.authentik_read_token,
|
||||
secret=broker_secret,
|
||||
)
|
||||
if (
|
||||
isinstance(broker_result, dict)
|
||||
and broker_result.get("status") in {"missing_config", "missing_secret"}
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_controller_delivery_unavailable",
|
||||
)
|
||||
attempted_delivery = False
|
||||
elif (
|
||||
isinstance(broker_result, dict)
|
||||
and broker_result.get("status") == "response"
|
||||
):
|
||||
artifact = _checked_controller_response(
|
||||
broker_result.get("response"),
|
||||
profile=profile,
|
||||
)
|
||||
if artifact is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown",
|
||||
message="forge_secret_controller_outcome_unknown",
|
||||
)
|
||||
else:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="bundle_ready",
|
||||
message="forge_secret_bundle_ready",
|
||||
artifact=artifact,
|
||||
)
|
||||
else:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown",
|
||||
message="forge_secret_controller_outcome_unknown",
|
||||
)
|
||||
except _ReaderFailure as exc:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked" if exc.status == "blocked" else "conflict",
|
||||
message="forge_secret_reader_proof_invalid",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never surface connector or held input details.
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status=("outcome_unknown" if attempted_delivery else "failed"),
|
||||
message=(
|
||||
"forge_secret_controller_outcome_unknown"
|
||||
if attempted_delivery
|
||||
else "forge_secret_input_verification_failed"
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - lock and DB errors are fixed safe codes.
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown" if attempted_delivery else "failed",
|
||||
message=(
|
||||
"forge_secret_controller_outcome_unknown"
|
||||
if attempted_delivery
|
||||
else "forge_secret_preparation_unavailable"
|
||||
),
|
||||
)
|
||||
|
||||
audit_ok = _audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="allowed",
|
||||
result=outcome,
|
||||
policy_id=policy_id,
|
||||
idempotency_key=request_key,
|
||||
)
|
||||
if not audit_ok:
|
||||
if attempted_delivery or outcome["status"] == "bundle_ready":
|
||||
return _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown",
|
||||
message="forge_secret_delivery_audit_persist_failed",
|
||||
)
|
||||
return _result(
|
||||
profile=profile,
|
||||
status="failed",
|
||||
message="forge_secret_audit_persist_failed",
|
||||
)
|
||||
return outcome
|
||||
@@ -164,6 +164,29 @@ def evaluate_policy(
|
||||
"scope_decision": "allowed" if allowed else None,
|
||||
}
|
||||
|
||||
if action == "forge.oidc.provision":
|
||||
# This is the only manual Authentik mutation exposed by AppOps. Requiring
|
||||
# exactly one row for the action prevents a wildcard or duplicate row
|
||||
# from becoming an alternate authorization path.
|
||||
policies = session.exec(select(Policy).where(Policy.action == action)).all()
|
||||
policy = policies[0] if len(policies) == 1 else None
|
||||
allowed = bool(
|
||||
source == "manual"
|
||||
and target == "itworx-forge"
|
||||
and policy is not None
|
||||
and policy.target == "itworx-forge"
|
||||
and policy.manual == "allowed"
|
||||
and policy.voice == "blocked"
|
||||
and policy.risk == "critical"
|
||||
)
|
||||
return {
|
||||
"decision": "allowed" if allowed else "blocked",
|
||||
"reason": "Forge OIDC provisioning requires one exact-target manual policy; voice is always blocked.",
|
||||
"risk": "critical",
|
||||
"policy_id": policy.id if allowed and policy is not None else None,
|
||||
"scope_decision": "allowed" if allowed else None,
|
||||
}
|
||||
|
||||
if action in _DEFAULT_DECISIONS:
|
||||
reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.')
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session, select
|
||||
@@ -11,6 +12,7 @@ from ..core.redaction import redact_message
|
||||
from ..core.time import utc_now
|
||||
from ..core.network_security import validate_connector_url
|
||||
from ..db.models import AppContainerMapping, AppSetting, AuditEvent, Connector, DiscoveryRun, Secret
|
||||
from ..schemas.forge_oidc import FORGE_OIDC_BINDING_SECRET_REF, ForgeOidcBindingV1
|
||||
from .audit_logger import log_event
|
||||
from .secret_store import empty_secret_status, get_secret, has_secret, secret_status, secret_storage_status, set_secret
|
||||
from . import safety_center_service
|
||||
@@ -219,6 +221,61 @@ def _apply_connector_config_to_runtime(session: Session, connector_id: str) -> N
|
||||
settings.apis[secret_env] = stored_secret
|
||||
elif secret_row and secret_row.value_json.get('value'):
|
||||
settings.apis[secret_env] = secret_row.value_json.get('value')
|
||||
if connector_id == 'authentik':
|
||||
_apply_forge_oidc_binding_to_runtime(session)
|
||||
|
||||
|
||||
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError('duplicate_json_key')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _apply_forge_oidc_binding_to_runtime(session: Session) -> bool:
|
||||
"""Load protected Forge resource IDs atomically and fail closed on bad state.
|
||||
|
||||
This fixed secret reference is intentionally absent from connector setup
|
||||
fields and response schemas. Generic Authentik settings can only update the
|
||||
Authentik URL/TLS/token inputs, never these remote resource bindings.
|
||||
"""
|
||||
internal_keys = (
|
||||
'AUTHENTIK_FORGE_OIDC_PROVIDER_ID',
|
||||
'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING',
|
||||
'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING',
|
||||
'AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING',
|
||||
)
|
||||
settings.apis.update({key: None for key in internal_keys})
|
||||
settings.apis['AUTHENTIK_FORGE_OIDC_BINDING_VALID'] = False
|
||||
try:
|
||||
raw = get_secret(session, FORGE_OIDC_BINDING_SECRET_REF, touch=False)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return False
|
||||
payload = json.loads(raw, object_pairs_hook=_unique_json_object)
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or set(payload) != {'version', 'providerId', 'scopeMappingIds'}
|
||||
or not isinstance(payload.get('scopeMappingIds'), dict)
|
||||
or set(payload['scopeMappingIds']) != {'openid', 'email', 'profile'}
|
||||
):
|
||||
return False
|
||||
binding = ForgeOidcBindingV1.model_validate(payload)
|
||||
except Exception: # noqa: BLE001 - binding failures never expose encrypted-store contents.
|
||||
return False
|
||||
|
||||
# Only publish values after the whole versioned record and distinct UUIDs
|
||||
# have passed validation. Keep the stored JSON out of runtime settings.
|
||||
values = {
|
||||
'AUTHENTIK_FORGE_OIDC_PROVIDER_ID': binding.provider_id,
|
||||
'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING': str(binding.scope_mapping_ids.openid),
|
||||
'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING': str(binding.scope_mapping_ids.email),
|
||||
'AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING': str(binding.scope_mapping_ids.profile),
|
||||
}
|
||||
settings.apis.update(values)
|
||||
settings.apis['AUTHENTIK_FORGE_OIDC_BINDING_VALID'] = True
|
||||
return True
|
||||
|
||||
|
||||
def apply_stored_connector_settings(session: Session, connector_ids: set[str] | None = None) -> None:
|
||||
|
||||
Reference in new issue
Block a user