Dev build 2026-06-19 13:51

This commit is contained in:
Ivo Oskamp 2026-06-19 13:51:33 +02:00
parent 4b05c48a73
commit 6b6025af16
13 changed files with 312 additions and 34 deletions

View File

@ -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';

View File

@ -583,7 +583,25 @@
</nav>
<div class="settings-pane" data-settings-pane="general">
<p class="setup-hint">Runtime configuration is currently controlled via environment variables in <code>stack/.env</code>. See the <strong>TECHNICAL.md</strong> document for the full list (timeouts, retries, scan caps, onboarding).</p>
<div class="card" data-admin-only>
<h3>Microsoft onboarding</h3>
<p class="setup-hint">Credentials for the Microsoft admin-consent / scan-app onboarding flow. Stored in the database.</p>
<form id="onboardingSettingsForm" class="onboarding-form" action="#" method="post">
<label class="onboarding-wide">App (client) ID
<input name="client_id" autocomplete="off" />
</label>
<label class="onboarding-wide">Redirect URI
<input name="redirect_uri" autocomplete="off" />
</label>
<label class="onboarding-wide">Client secret
<input name="client_secret" type="password" autocomplete="new-password" />
</label>
<div class="onboarding-actions">
<button class="btn btn-primary" type="submit">Save</button>
<span id="onboardingSettingsStatus" class="setup-hint"></span>
</div>
</form>
</div>
</div>
<div class="settings-pane" data-settings-pane="users" hidden>

View File

@ -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}

View File

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

View File

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

View File

@ -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)]

View File

@ -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")

View File

@ -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"

View File

@ -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",
}

View File

@ -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

View File

@ -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()

View File

@ -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:

View File

@ -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