diff --git a/containers/clearview/site/app.js b/containers/clearview/site/app.js
index 7e5d9d5..aeb4901 100644
--- a/containers/clearview/site/app.js
+++ b/containers/clearview/site/app.js
@@ -1871,9 +1871,57 @@ function renderUserBadge(me) {
reloadUsersTable().catch(function (err) { console.error('users reload failed', err); });
} else if (sub === 'audit') {
reloadAuditTable().catch(function (err) { console.error('audit reload failed', err); });
+ } else if (sub === 'general') {
+ wireOnboardingSettingsOnce();
+ loadOnboardingSettings().catch(function (err) { console.error('onboarding settings load failed', err); });
}
}
+ let _onboardingSettingsWired = false;
+
+ function wireOnboardingSettingsOnce() {
+ if (_onboardingSettingsWired) return;
+ const form = document.getElementById('onboardingSettingsForm');
+ if (!form) return;
+ _onboardingSettingsWired = true;
+ form.addEventListener('submit', async function (ev) {
+ ev.preventDefault();
+ const statusEl = document.getElementById('onboardingSettingsStatus');
+ const fd = new FormData(form);
+ const body = {
+ client_id: (fd.get('client_id') || '').trim(),
+ redirect_uri: (fd.get('redirect_uri') || '').trim(),
+ };
+ const secret = (fd.get('client_secret') || '').trim();
+ // Blank secret = keep the stored one (write-only field).
+ if (secret) body.client_secret = secret;
+ try {
+ await requestJson('/api/settings/onboarding', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (statusEl) statusEl.textContent = 'Saved.';
+ await loadOnboardingSettings();
+ } catch (err) {
+ if (statusEl) statusEl.textContent = 'Save failed: ' + err.message;
+ }
+ });
+ }
+
+ async function loadOnboardingSettings() {
+ const form = document.getElementById('onboardingSettingsForm');
+ if (!form) return;
+ const user = window.__clearviewUser;
+ if (!user || user.role !== 'admin') return; // endpoint is admin-only
+ const data = await requestJson('/api/settings/onboarding');
+ form.querySelector('[name="client_id"]').value = data.client_id || '';
+ form.querySelector('[name="redirect_uri"]').value = data.redirect_uri || '';
+ const secretInput = form.querySelector('[name="client_secret"]');
+ secretInput.value = '';
+ secretInput.placeholder = data.client_secret_set ? '•••••••• (leave blank to keep)' : 'Not set';
+ }
+
function navigateTo(route) {
var hash;
if (route === 'scan-sharepoint') hash = '#/scan/sharepoint';
diff --git a/containers/clearview/site/index.html b/containers/clearview/site/index.html
index d3c37cc..ff3bc89 100644
--- a/containers/clearview/site/index.html
+++ b/containers/clearview/site/index.html
@@ -583,7 +583,25 @@
-
Runtime configuration is currently controlled via environment variables in stack/.env. See the TECHNICAL.md document for the full list (timeouts, retries, scan caps, onboarding).
+
+
Microsoft onboarding
+
Credentials for the Microsoft admin-consent / scan-app onboarding flow. Stored in the database.
+
+
diff --git a/containers/clearview/src/clearview_app/api_onboarding.py b/containers/clearview/src/clearview_app/api_onboarding.py
index 979107c..dc9e070 100644
--- a/containers/clearview/src/clearview_app/api_onboarding.py
+++ b/containers/clearview/src/clearview_app/api_onboarding.py
@@ -1,9 +1,17 @@
-"""Microsoft onboarding routes (admin-consent connect + scan-app creation)."""
+"""Microsoft onboarding routes (admin-consent connect + scan-app creation).
+
+Onboarding credentials live in the database (see settings_service); each route
+loads them per request and passes them into the onboarding helpers.
+"""
from __future__ import annotations
-from fastapi import APIRouter, HTTPException
-from fastapi.responses import RedirectResponse
+from typing import Annotated
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import RedirectResponse
+from sqlalchemy.orm import Session
+
+from .auth.dependencies import get_db
from .onboarding import (
OnboardingError,
consume_callback_state,
@@ -15,14 +23,18 @@ from .schemas import (
CreateScanAppRequest,
CreateScanAppResponse,
)
+from .settings_service import get_onboarding_config
router = APIRouter()
+DbDep = Annotated[Session, Depends(get_db)]
+
@router.post("/api/onboarding/create-scan-app", response_model=CreateScanAppResponse)
-def onboarding_create_scan_app(payload: CreateScanAppRequest) -> CreateScanAppResponse:
+def onboarding_create_scan_app(payload: CreateScanAppRequest, db: DbDep) -> CreateScanAppResponse:
try:
result = create_scan_app_for_tenant(
+ get_onboarding_config(db),
tenant_id=payload.tenant_id,
display_name=payload.display_name,
)
@@ -42,9 +54,9 @@ def onboarding_create_scan_app(payload: CreateScanAppRequest) -> CreateScanAppRe
@router.get("/api/onboarding/microsoft/connect-url", response_model=ConnectMicrosoftResponse)
-def onboarding_microsoft_connect_url() -> ConnectMicrosoftResponse:
+def onboarding_microsoft_connect_url(db: DbDep) -> ConnectMicrosoftResponse:
try:
- return ConnectMicrosoftResponse(connect_url=create_connect_url())
+ return ConnectMicrosoftResponse(connect_url=create_connect_url(get_onboarding_config(db)))
except OnboardingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -70,7 +82,5 @@ def onboarding_microsoft_callback(
@router.get("/api/onboarding/status")
-def onboarding_status() -> dict[str, bool]:
- from . import config
- automated = bool(config.ONBOARDING_CLIENT_ID and config.ONBOARDING_CLIENT_SECRET and config.ONBOARDING_REDIRECT_URI)
- return {"automated_available": automated}
+def onboarding_status(db: DbDep) -> dict[str, bool]:
+ return {"automated_available": get_onboarding_config(db).is_complete}
diff --git a/containers/clearview/src/clearview_app/api_settings.py b/containers/clearview/src/clearview_app/api_settings.py
new file mode 100644
index 0000000..b342e10
--- /dev/null
+++ b/containers/clearview/src/clearview_app/api_settings.py
@@ -0,0 +1,43 @@
+"""Admin settings routes (database-backed app configuration)."""
+from __future__ import annotations
+
+from typing import Annotated
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from .auth.dependencies import get_db, require_admin
+from .schemas import OnboardingSettings, OnboardingSettingsUpdate
+from .settings_service import get_onboarding_config, set_onboarding_config
+
+# Every route here requires an admin session.
+router = APIRouter(dependencies=[Depends(require_admin)])
+
+DbDep = Annotated[Session, Depends(get_db)]
+
+
+@router.get("/api/settings/onboarding", response_model=OnboardingSettings)
+def read_onboarding_settings(db: DbDep) -> OnboardingSettings:
+ cfg = get_onboarding_config(db)
+ # The secret is write-only: report only whether one is stored.
+ return OnboardingSettings(
+ client_id=cfg.client_id,
+ redirect_uri=cfg.redirect_uri,
+ client_secret_set=bool(cfg.client_secret),
+ )
+
+
+@router.put("/api/settings/onboarding", response_model=OnboardingSettings)
+def update_onboarding_settings(payload: OnboardingSettingsUpdate, db: DbDep) -> OnboardingSettings:
+ set_onboarding_config(
+ db,
+ client_id=payload.client_id,
+ redirect_uri=payload.redirect_uri,
+ client_secret=payload.client_secret,
+ )
+ cfg = get_onboarding_config(db)
+ return OnboardingSettings(
+ client_id=cfg.client_id,
+ redirect_uri=cfg.redirect_uri,
+ client_secret_set=bool(cfg.client_secret),
+ )
diff --git a/containers/clearview/src/clearview_app/config.py b/containers/clearview/src/clearview_app/config.py
index 289667c..0681c0e 100644
--- a/containers/clearview/src/clearview_app/config.py
+++ b/containers/clearview/src/clearview_app/config.py
@@ -26,9 +26,8 @@ SCAN_JOB_POLL_INTERVAL_SEC = _int_env("SCAN_JOB_POLL_INTERVAL_SEC", 3)
# Placeholder mode until Graph/SharePoint auth integration is implemented.
SHAREPOINT_SCAN_MODE = os.getenv("SHAREPOINT_SCAN_MODE", "sharepoint_app_only")
-ONBOARDING_CLIENT_ID = os.getenv("ONBOARDING_CLIENT_ID", "")
-ONBOARDING_CLIENT_SECRET = os.getenv("ONBOARDING_CLIENT_SECRET", "")
-ONBOARDING_REDIRECT_URI = os.getenv("ONBOARDING_REDIRECT_URI", "")
+# Onboarding (Microsoft admin-consent / scan-app) credentials are stored in the
+# database (see settings_service), not in the environment.
SCAN_HTTP_TIMEOUT_SEC = _int_env("SCAN_HTTP_TIMEOUT_SEC", 30)
SCAN_HTTP_MAX_RETRIES = _int_env("SCAN_HTTP_MAX_RETRIES", 3)
diff --git a/containers/clearview/src/clearview_app/main.py b/containers/clearview/src/clearview_app/main.py
index a86f603..d025aad 100644
--- a/containers/clearview/src/clearview_app/main.py
+++ b/containers/clearview/src/clearview_app/main.py
@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
from .api_jobs import router as jobs_router
from .api_onboarding import router as onboarding_router
+from .api_settings import router as settings_router
from .api_tenants import router as tenants_router
from .auth.dependencies import require_user
from .auth.router import router as auth_router
@@ -55,6 +56,7 @@ app.include_router(auth_router)
# Admin endpoints — already enforce require_admin internally.
app.include_router(users_router)
+app.include_router(settings_router)
# Existing routers gated by an authenticated session.
_protected = [Depends(require_user)]
diff --git a/containers/clearview/src/clearview_app/migrations/versions/0004_app_settings.py b/containers/clearview/src/clearview_app/migrations/versions/0004_app_settings.py
new file mode 100644
index 0000000..620eca3
--- /dev/null
+++ b/containers/clearview/src/clearview_app/migrations/versions/0004_app_settings.py
@@ -0,0 +1,37 @@
+"""Create app_settings table (database-backed runtime configuration).
+
+Revision ID: 0004_app_settings
+Revises: 0003_auth_tables
+Create Date: 2026-06-19
+
+The baseline (0001) creates every table in ``clearview_app.models.Base`` via
+``create_all``, so a *fresh* database already has ``app_settings`` once the
+model exists. Only databases stamped at the baseline before this table was added
+need it created here — hence the guarded create, which is a no-op on fresh DBs.
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "0004_app_settings"
+down_revision = "0003_auth_tables"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ if "app_settings" not in sa.inspect(bind).get_table_names():
+ op.create_table(
+ "app_settings",
+ sa.Column("key", sa.String(length=64), primary_key=True),
+ sa.Column("value", sa.Text(), nullable=True),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ if "app_settings" in sa.inspect(bind).get_table_names():
+ op.drop_table("app_settings")
diff --git a/containers/clearview/src/clearview_app/models.py b/containers/clearview/src/clearview_app/models.py
index e67483e..5ff6e48 100644
--- a/containers/clearview/src/clearview_app/models.py
+++ b/containers/clearview/src/clearview_app/models.py
@@ -15,6 +15,23 @@ class Base(DeclarativeBase):
pass
+class AppSetting(Base):
+ """Key-value application configuration, stored in the database.
+
+ Per the project convention, runtime configuration lives in the database (the
+ stack only carries what the app needs to start). Values are stored as plain
+ text, consistent with how tenant secrets/keys are stored on TenantProfile.
+ """
+
+ __tablename__ = "app_settings"
+
+ key: Mapped[str] = mapped_column(String(64), primary_key=True)
+ value: Mapped[str | None] = mapped_column(Text, nullable=True)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
+ )
+
+
class TenantProfile(Base):
__tablename__ = "tenant_profiles"
diff --git a/containers/clearview/src/clearview_app/onboarding.py b/containers/clearview/src/clearview_app/onboarding.py
index 92d41f1..92018ee 100644
--- a/containers/clearview/src/clearview_app/onboarding.py
+++ b/containers/clearview/src/clearview_app/onboarding.py
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
import requests
-from .config import ONBOARDING_CLIENT_ID, ONBOARDING_CLIENT_SECRET, ONBOARDING_REDIRECT_URI
+from .settings_service import OnboardingConfig
SHAREPOINT_RESOURCE_APP_ID = "00000003-0000-0ff1-ce00-000000000000"
_STATE_TTL_SEC = 600
@@ -31,13 +31,13 @@ class OnboardingError(RuntimeError):
_state_store: dict[str, float] = {}
-def create_connect_url() -> str:
- _validate_onboarding_config()
+def create_connect_url(cfg: OnboardingConfig) -> str:
+ _validate_onboarding_config(cfg)
state = _issue_state_token()
query = {
- "client_id": ONBOARDING_CLIENT_ID,
- "redirect_uri": ONBOARDING_REDIRECT_URI,
+ "client_id": cfg.client_id,
+ "redirect_uri": cfg.redirect_uri,
"state": state,
}
return f"https://login.microsoftonline.com/organizations/adminconsent?{urlencode(query)}"
@@ -51,16 +51,16 @@ def consume_callback_state(state: str) -> bool:
return (time.time() - created_at) <= _STATE_TTL_SEC
-def create_scan_app_for_tenant(tenant_id: str, display_name: str) -> CreatedScanApp:
+def create_scan_app_for_tenant(cfg: OnboardingConfig, tenant_id: str, display_name: str) -> CreatedScanApp:
tenant = (tenant_id or "").strip()
app_name = (display_name or "").strip() or f"Clearview Scan App {uuid.uuid4().hex[:8]}"
if not tenant:
raise OnboardingError("tenant_id is required")
- _validate_onboarding_config()
+ _validate_onboarding_config(cfg)
- graph_token = _get_graph_token_for_tenant(tenant)
+ graph_token = _get_graph_token_for_tenant(cfg, tenant)
headers = {
"Authorization": f"Bearer {graph_token}",
"Content-Type": "application/json",
@@ -145,23 +145,27 @@ def _cleanup_states() -> None:
_state_store.pop(key, None)
-def _validate_onboarding_config() -> None:
+def _validate_onboarding_config(cfg: OnboardingConfig) -> None:
missing = []
- if not ONBOARDING_CLIENT_ID:
- missing.append("ONBOARDING_CLIENT_ID")
- if not ONBOARDING_CLIENT_SECRET:
- missing.append("ONBOARDING_CLIENT_SECRET")
- if not ONBOARDING_REDIRECT_URI:
- missing.append("ONBOARDING_REDIRECT_URI")
+ if not cfg.client_id:
+ missing.append("client_id")
+ if not cfg.client_secret:
+ missing.append("client_secret")
+ if not cfg.redirect_uri:
+ missing.append("redirect_uri")
if missing:
- raise OnboardingError("Missing onboarding config: " + ", ".join(missing))
+ raise OnboardingError(
+ "Onboarding is not configured yet (missing: "
+ + ", ".join(missing)
+ + "). Set it under Settings."
+ )
-def _get_graph_token_for_tenant(tenant_id: str) -> str:
+def _get_graph_token_for_tenant(cfg: OnboardingConfig, tenant_id: str) -> str:
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
data = {
- "client_id": ONBOARDING_CLIENT_ID,
- "client_secret": ONBOARDING_CLIENT_SECRET,
+ "client_id": cfg.client_id,
+ "client_secret": cfg.client_secret,
"grant_type": "client_credentials",
"scope": "https://graph.microsoft.com/.default",
}
diff --git a/containers/clearview/src/clearview_app/schemas.py b/containers/clearview/src/clearview_app/schemas.py
index 910ba14..c557036 100644
--- a/containers/clearview/src/clearview_app/schemas.py
+++ b/containers/clearview/src/clearview_app/schemas.py
@@ -158,3 +158,19 @@ class CreateScanAppResponse(BaseModel):
app_object_id: str
service_principal_id: str
display_name: str
+
+
+class OnboardingSettings(BaseModel):
+ """Onboarding config as shown to an admin. The secret is never returned;
+ only whether one is stored."""
+
+ client_id: str
+ redirect_uri: str
+ client_secret_set: bool
+
+
+class OnboardingSettingsUpdate(BaseModel):
+ client_id: str = ""
+ redirect_uri: str = ""
+ # None / omitted = keep the stored secret; a string overwrites it.
+ client_secret: str | None = None
diff --git a/containers/clearview/src/clearview_app/settings_service.py b/containers/clearview/src/clearview_app/settings_service.py
new file mode 100644
index 0000000..5851f21
--- /dev/null
+++ b/containers/clearview/src/clearview_app/settings_service.py
@@ -0,0 +1,71 @@
+"""Database-backed application settings.
+
+Runtime configuration lives in the database (the stack only carries what the app
+needs to start). This module reads/writes the ``app_settings`` key-value table.
+Values are stored as plain text, consistent with how tenant secrets are stored
+elsewhere in the app.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from sqlalchemy.orm import Session
+
+from .models import AppSetting
+
+_KEY_CLIENT_ID = "onboarding_client_id"
+_KEY_CLIENT_SECRET = "onboarding_client_secret"
+_KEY_REDIRECT_URI = "onboarding_redirect_uri"
+
+
+@dataclass(frozen=True)
+class OnboardingConfig:
+ """Microsoft onboarding (admin-consent / scan-app creation) credentials."""
+
+ client_id: str
+ client_secret: str
+ redirect_uri: str
+
+ @property
+ def is_complete(self) -> bool:
+ return bool(self.client_id and self.client_secret and self.redirect_uri)
+
+
+def _get(db: Session, key: str) -> str:
+ row = db.get(AppSetting, key)
+ return (row.value or "") if row is not None else ""
+
+
+def _set(db: Session, key: str, value: str | None) -> None:
+ row = db.get(AppSetting, key)
+ if row is None:
+ db.add(AppSetting(key=key, value=value))
+ else:
+ row.value = value
+
+
+def get_onboarding_config(db: Session) -> OnboardingConfig:
+ return OnboardingConfig(
+ client_id=_get(db, _KEY_CLIENT_ID),
+ client_secret=_get(db, _KEY_CLIENT_SECRET),
+ redirect_uri=_get(db, _KEY_REDIRECT_URI),
+ )
+
+
+def set_onboarding_config(
+ db: Session,
+ *,
+ client_id: str,
+ redirect_uri: str,
+ client_secret: str | None = None,
+) -> None:
+ """Update onboarding settings and commit.
+
+ ``client_secret=None`` keeps the stored secret untouched, so the admin UI can
+ present a write-only field that is left blank to retain the current value.
+ """
+ _set(db, _KEY_CLIENT_ID, (client_id or "").strip())
+ _set(db, _KEY_REDIRECT_URI, (redirect_uri or "").strip())
+ if client_secret is not None:
+ _set(db, _KEY_CLIENT_SECRET, client_secret.strip())
+ db.commit()
diff --git a/containers/clearview/src/clearview_app/version.py b/containers/clearview/src/clearview_app/version.py
index 201ae44..822eebb 100644
--- a/containers/clearview/src/clearview_app/version.py
+++ b/containers/clearview/src/clearview_app/version.py
@@ -7,7 +7,7 @@ history, so operators can see exactly which image build is running.
from __future__ import annotations
VERSION = "v0.2.0"
-BUILD = 1
+BUILD = 2
def display_version() -> str:
diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md
index 599267f..d15fe0b 100644
--- a/docs/changelog-develop.md
+++ b/docs/changelog-develop.md
@@ -2,6 +2,19 @@
This file documents changes on the develop branch of this project.
+## 2026-06-19 — Onboarding config moved from env to the database
+
+### Added
+- Database-backed application settings, per the convention that all runtime config lives in the DB (the stack only carries what the app needs to start). New `app_settings` key-value table (`models.AppSetting`, plain-text values — consistent with how tenant secrets are already stored), Alembic migration `0004_app_settings`, and `settings_service.py` with `get_onboarding_config(db)` / `set_onboarding_config(db, ...)`.
+- Admin-only settings API (`api_settings.py`, gated by `require_admin`): `GET /api/settings/onboarding` (returns `client_id`, `redirect_uri`, and a `client_secret_set` flag — the secret itself is never returned) and `PUT /api/settings/onboarding` (a blank `client_secret` keeps the stored one). Wired into `main.py`.
+- Settings → General now has an admin form to manage the Microsoft onboarding credentials (`index.html` + `app.js`: load on tab open, write-only secret field with a "leave blank to keep" placeholder).
+
+### Changed
+- `onboarding.py` functions now take an `OnboardingConfig` parameter instead of reading module-level `ONBOARDING_*` constants; `api_onboarding.py` loads the config from the DB per request and passes it in. `GET /api/onboarding/status` now reports `automated_available` from the stored config.
+- Removed `ONBOARDING_CLIENT_ID/SECRET/REDIRECT_URI` from `config.py` (and earlier from the stack). Background: Clearview originally had no login/settings layer — that was added later — which is why onboarding had been wired through env in the first place.
+- **Migration note:** the baseline (`0001`) builds the schema via `Base.metadata.create_all`, so a fresh DB already gets `app_settings` from the model; `0004` therefore guards its `create_table` (no-op on fresh DBs, creates it on databases stamped at the baseline before this table existed).
+- Verified end-to-end against a throwaway Postgres + the built image: migrations reach `0004`, admin setup/login, settings GET/PUT (secret never returned, blank-secret keeps the stored value), `onboarding/status` flips to `automated_available:true` after configuring, values persisted in `app_settings`, and the endpoints 401 without an admin session. `app.js` passes `node --check`.
+
## 2026-06-19 — Stack: no silent defaults + per-environment naming
### Changed