835 lines
30 KiB
Python
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,
|
|
)
|