Files
Unraid-AppOps-Gateway-Public/backend/app/services/forge_oidc_provisioning.py
T

835 lines
30 KiB
Python

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