Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions context_intelligence_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,28 @@ def _build_identity_map_from(
return {oid.lower(): meta["id"] for oid, meta in identity_dict.items()}


def _default_identity_store_path(filename: str) -> str:
"""Return a host-install-writable default path for an identity-map store file.

These paths used to default to "/data/identity/<filename>" -- an Azure
Files volume path baked in for the container deployment. On a plain
(non-container) host install nothing mounts /data, so the
seed-on-first-boot write in IdentityStore.seed() silently failed with
PermissionError: the server kept running fail-closed on the in-memory
map, but nothing was ever persisted to disk, and any key added later via
the /admin API would vanish on restart.

Default to the invoking user's own writable data dir instead, using the
same ~/.local/share/ci-server/... layout already illustrated as the
host-install convention in YamlConfigSettingsSource's docstring above
(its blob_path / log_path example values). Container deployments are
unaffected: they set these paths explicitly via env/YAML (e.g.
amplifier-online.yaml sets entra_identities_store_path to the mounted
/data volume) -- this default only matters when nothing overrides it.
"""
return str(Path.home() / ".local" / "share" / "ci-server" / "identity" / filename)


class YamlConfigSettingsSource(PydanticBaseSettingsSource):
"""Load settings from a YAML configuration file.

Expand Down Expand Up @@ -695,14 +717,21 @@ def _normalize_service_role_fields(cls, v: object) -> str:
# -------------------------------------------------------------------------
# Durable identity-map store paths
# -------------------------------------------------------------------------
# These paths control where the two JSON identity-map files live on the
# Azure Files volume (/data). Both are env/YAML overridable to allow
# non-default layouts in development or custom deployments.
# These paths control where the two JSON identity-map files live. Both are
# env/YAML overridable to allow non-default layouts in development,
# custom deployments, or containers -- e.g. amplifier-online.yaml sets
# entra_identities_store_path explicitly to the mounted Azure Files
# volume (/data/identity/entra-identities.json).
#
# The DEFAULT (see _default_identity_store_path()) is a host-writable
# per-user path, not /data/... -- see that helper's docstring for why.
#
# api_keys_store_path: SHA-256 digest → contributor map (static mode)
# entra_identities_store_path: OID → contributor map (entra mode)
api_keys_store_path: str = "/data/identity/api-keys.json"
entra_identities_store_path: str = "/data/identity/entra-identities.json"
api_keys_store_path: str = _default_identity_store_path("api-keys.json")
entra_identities_store_path: str = _default_identity_store_path(
"entra-identities.json"
)

# -------------------------------------------------------------------------
# Neo4j
Expand Down
56 changes: 54 additions & 2 deletions context_intelligence_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,56 @@ def create_asgi_app(

# Module-level ASGI app used by Gunicorn: context_intelligence_server.main:asgi_app
# The raw `app` is kept for internal use and testing against un-authed routes.
asgi_app: BearerTokenMiddleware = create_asgi_app()
#
# LAZY construction (PEP 562 module __getattr__), NOT built at import time.
#
# create_asgi_app() enforces the auth guard: it raises RuntimeError when no
# authentication is configured at all (see its docstring / _assert_* helpers).
# That guard is correct and must NOT be weakened. The problem was *timing*:
# this module used to call create_asgi_app() unconditionally at import time,
# which meant the console-script entry point (`context-intelligence-server`)
# imports `main` to reach `main()`, so even `--help`/`--version` constructed
# the whole ASGI app and hit the guard. An operator with a broken/absent
# config couldn't ask the binary what version it was -- exactly when they
# most need to.
#
# `_asgi_app` is the cache; `get_asgi_app()` builds-and-caches on first call;
# `__getattr__` makes `context_intelligence_server.main.asgi_app` /
# `from context_intelligence_server.main import asgi_app` keep working for
# anything that reads the module attribute directly (gunicorn's `load()`,
# tests) -- construction (and therefore the auth guard) now happens on first
# access instead of at import time. Actually serving (`run()` -> `_App.load()`
# -> `get_asgi_app()`) still triggers it, so an unconfigured server still
# fails loud exactly as before -- only bare import / --help / --version are
# spared.
_asgi_app: BearerTokenMiddleware | None = None


def get_asgi_app() -> BearerTokenMiddleware:
"""Return the module-level ASGI app, constructing it on first call.

This is the single lazy-construction point. Internal code (``_App.load()``
below) MUST call this function rather than referencing a bare ``asgi_app``
global -- a bare name reference is a normal global-variable lookup and
would NOT go through ``__getattr__``, so it would raise ``NameError``
once the unconditional module-level assignment is removed.
"""
global _asgi_app
if _asgi_app is None:
_asgi_app = create_asgi_app()
return _asgi_app


def __getattr__(name: str) -> Any:
"""PEP 562 module-level lazy attribute access for ``asgi_app``.

Only intercepts ``asgi_app`` (the sole lazily-constructed module
attribute); anything else is a genuine ``AttributeError``, matching
normal module attribute-access semantics.
"""
if name == "asgi_app":
return get_asgi_app()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -886,6 +935,9 @@ def load_config(self) -> None:
self.cfg.set(key, value)

def load(self) -> Any:
return asgi_app
# get_asgi_app() (not the bare `asgi_app` global) -- this is
# where lazy construction actually happens for a real serve,
# and where the auth guard still fires if unconfigured.
return get_asgi_app()

_App().run()
50 changes: 43 additions & 7 deletions tests/test_identity_map_wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
T1 (config additions):
- admin_api_key: str | None — env AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_ADMIN_API_KEY
or YAML admin_api_key; consistent with api_key/api_keys pattern.
- api_keys_store_path: str — default "/data/identity/api-keys.json"; env/YAML override.
- entra_identities_store_path: str — default "/data/identity/entra-identities.json";
env/YAML override.
- api_keys_store_path: str — default is a host-writable per-user path
(~/.local/share/ci-server/identity/api-keys.json), NOT the container's
/data/...; env/YAML override.
- entra_identities_store_path: str — same host-writable default pattern
(~/.local/share/ci-server/identity/entra-identities.json); env/YAML
override.

T3 (IdentityStore wired to BOTH resolvers):
Static mode:
Expand Down Expand Up @@ -154,9 +157,27 @@ class TestT1ApiKeysStorePath:
"""api_keys_store_path: default + env + YAML override."""

def test_default(self) -> None:
"""Default is a host-writable per-user path, not the container's /data/...

See _default_identity_store_path() in config.py: the bug this guards
against is a silent PermissionError on a host install where /data
does not exist/is not writable.
"""
from pathlib import Path # noqa: PLC0415

from context_intelligence_server.config import Settings # noqa: PLC0415

assert Settings().api_keys_store_path == "/data/identity/api-keys.json"
default_path = Settings().api_keys_store_path
assert not default_path.startswith("/data/")
expected = (
Path.home()
/ ".local"
/ "share"
/ "ci-server"
/ "identity"
/ "api-keys.json"
)
assert default_path == str(expected)

def test_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(
Expand All @@ -182,12 +203,27 @@ class TestT1EntraIdentitiesStorePath:
"""entra_identities_store_path: default + env + YAML override."""

def test_default(self) -> None:
"""Default is a host-writable per-user path, not the container's /data/...

Container deployments (e.g. amplifier-online.yaml) set this
explicitly via env var, so this default only matters for a plain
host install with no override configured.
"""
from pathlib import Path # noqa: PLC0415

from context_intelligence_server.config import Settings # noqa: PLC0415

assert (
Settings().entra_identities_store_path
== "/data/identity/entra-identities.json"
default_path = Settings().entra_identities_store_path
assert not default_path.startswith("/data/")
expected = (
Path.home()
/ ".local"
/ "share"
/ "ci-server"
/ "identity"
/ "entra-identities.json"
)
assert default_path == str(expected)

def test_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(
Expand Down
Loading
Loading