Dev build 2026-06-19 13:51
This commit is contained in:
parent
4b05c48a73
commit
6b6025af16
@ -1871,9 +1871,57 @@ function renderUserBadge(me) {
|
|||||||
reloadUsersTable().catch(function (err) { console.error('users reload failed', err); });
|
reloadUsersTable().catch(function (err) { console.error('users reload failed', err); });
|
||||||
} else if (sub === 'audit') {
|
} else if (sub === 'audit') {
|
||||||
reloadAuditTable().catch(function (err) { console.error('audit reload failed', err); });
|
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) {
|
function navigateTo(route) {
|
||||||
var hash;
|
var hash;
|
||||||
if (route === 'scan-sharepoint') hash = '#/scan/sharepoint';
|
if (route === 'scan-sharepoint') hash = '#/scan/sharepoint';
|
||||||
|
|||||||
@ -583,7 +583,25 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="settings-pane" data-settings-pane="general">
|
<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>
|
||||||
|
|
||||||
<div class="settings-pane" data-settings-pane="users" hidden>
|
<div class="settings-pane" data-settings-pane="users" hidden>
|
||||||
|
|||||||
@ -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 __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from typing import Annotated
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
|
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 (
|
from .onboarding import (
|
||||||
OnboardingError,
|
OnboardingError,
|
||||||
consume_callback_state,
|
consume_callback_state,
|
||||||
@ -15,14 +23,18 @@ from .schemas import (
|
|||||||
CreateScanAppRequest,
|
CreateScanAppRequest,
|
||||||
CreateScanAppResponse,
|
CreateScanAppResponse,
|
||||||
)
|
)
|
||||||
|
from .settings_service import get_onboarding_config
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
DbDep = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/onboarding/create-scan-app", response_model=CreateScanAppResponse)
|
@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:
|
try:
|
||||||
result = create_scan_app_for_tenant(
|
result = create_scan_app_for_tenant(
|
||||||
|
get_onboarding_config(db),
|
||||||
tenant_id=payload.tenant_id,
|
tenant_id=payload.tenant_id,
|
||||||
display_name=payload.display_name,
|
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)
|
@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:
|
try:
|
||||||
return ConnectMicrosoftResponse(connect_url=create_connect_url())
|
return ConnectMicrosoftResponse(connect_url=create_connect_url(get_onboarding_config(db)))
|
||||||
except OnboardingError as exc:
|
except OnboardingError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@ -70,7 +82,5 @@ def onboarding_microsoft_callback(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/onboarding/status")
|
@router.get("/api/onboarding/status")
|
||||||
def onboarding_status() -> dict[str, bool]:
|
def onboarding_status(db: DbDep) -> dict[str, bool]:
|
||||||
from . import config
|
return {"automated_available": get_onboarding_config(db).is_complete}
|
||||||
automated = bool(config.ONBOARDING_CLIENT_ID and config.ONBOARDING_CLIENT_SECRET and config.ONBOARDING_REDIRECT_URI)
|
|
||||||
return {"automated_available": automated}
|
|
||||||
|
|||||||
43
containers/clearview/src/clearview_app/api_settings.py
Normal file
43
containers/clearview/src/clearview_app/api_settings.py
Normal 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),
|
||||||
|
)
|
||||||
@ -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.
|
# Placeholder mode until Graph/SharePoint auth integration is implemented.
|
||||||
SHAREPOINT_SCAN_MODE = os.getenv("SHAREPOINT_SCAN_MODE", "sharepoint_app_only")
|
SHAREPOINT_SCAN_MODE = os.getenv("SHAREPOINT_SCAN_MODE", "sharepoint_app_only")
|
||||||
|
|
||||||
ONBOARDING_CLIENT_ID = os.getenv("ONBOARDING_CLIENT_ID", "")
|
# Onboarding (Microsoft admin-consent / scan-app) credentials are stored in the
|
||||||
ONBOARDING_CLIENT_SECRET = os.getenv("ONBOARDING_CLIENT_SECRET", "")
|
# database (see settings_service), not in the environment.
|
||||||
ONBOARDING_REDIRECT_URI = os.getenv("ONBOARDING_REDIRECT_URI", "")
|
|
||||||
|
|
||||||
SCAN_HTTP_TIMEOUT_SEC = _int_env("SCAN_HTTP_TIMEOUT_SEC", 30)
|
SCAN_HTTP_TIMEOUT_SEC = _int_env("SCAN_HTTP_TIMEOUT_SEC", 30)
|
||||||
SCAN_HTTP_MAX_RETRIES = _int_env("SCAN_HTTP_MAX_RETRIES", 3)
|
SCAN_HTTP_MAX_RETRIES = _int_env("SCAN_HTTP_MAX_RETRIES", 3)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from .api_jobs import router as jobs_router
|
from .api_jobs import router as jobs_router
|
||||||
from .api_onboarding import router as onboarding_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 .api_tenants import router as tenants_router
|
||||||
from .auth.dependencies import require_user
|
from .auth.dependencies import require_user
|
||||||
from .auth.router import router as auth_router
|
from .auth.router import router as auth_router
|
||||||
@ -55,6 +56,7 @@ app.include_router(auth_router)
|
|||||||
|
|
||||||
# Admin endpoints — already enforce require_admin internally.
|
# Admin endpoints — already enforce require_admin internally.
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
# Existing routers gated by an authenticated session.
|
# Existing routers gated by an authenticated session.
|
||||||
_protected = [Depends(require_user)]
|
_protected = [Depends(require_user)]
|
||||||
|
|||||||
@ -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")
|
||||||
@ -15,6 +15,23 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
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):
|
class TenantProfile(Base):
|
||||||
__tablename__ = "tenant_profiles"
|
__tablename__ = "tenant_profiles"
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from urllib.parse import urlencode
|
|||||||
|
|
||||||
import requests
|
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"
|
SHAREPOINT_RESOURCE_APP_ID = "00000003-0000-0ff1-ce00-000000000000"
|
||||||
_STATE_TTL_SEC = 600
|
_STATE_TTL_SEC = 600
|
||||||
@ -31,13 +31,13 @@ class OnboardingError(RuntimeError):
|
|||||||
_state_store: dict[str, float] = {}
|
_state_store: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
def create_connect_url() -> str:
|
def create_connect_url(cfg: OnboardingConfig) -> str:
|
||||||
_validate_onboarding_config()
|
_validate_onboarding_config(cfg)
|
||||||
state = _issue_state_token()
|
state = _issue_state_token()
|
||||||
|
|
||||||
query = {
|
query = {
|
||||||
"client_id": ONBOARDING_CLIENT_ID,
|
"client_id": cfg.client_id,
|
||||||
"redirect_uri": ONBOARDING_REDIRECT_URI,
|
"redirect_uri": cfg.redirect_uri,
|
||||||
"state": state,
|
"state": state,
|
||||||
}
|
}
|
||||||
return f"https://login.microsoftonline.com/organizations/adminconsent?{urlencode(query)}"
|
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
|
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()
|
tenant = (tenant_id or "").strip()
|
||||||
app_name = (display_name or "").strip() or f"Clearview Scan App {uuid.uuid4().hex[:8]}"
|
app_name = (display_name or "").strip() or f"Clearview Scan App {uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
if not tenant:
|
if not tenant:
|
||||||
raise OnboardingError("tenant_id is required")
|
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 = {
|
headers = {
|
||||||
"Authorization": f"Bearer {graph_token}",
|
"Authorization": f"Bearer {graph_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -145,23 +145,27 @@ def _cleanup_states() -> None:
|
|||||||
_state_store.pop(key, None)
|
_state_store.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
def _validate_onboarding_config() -> None:
|
def _validate_onboarding_config(cfg: OnboardingConfig) -> None:
|
||||||
missing = []
|
missing = []
|
||||||
if not ONBOARDING_CLIENT_ID:
|
if not cfg.client_id:
|
||||||
missing.append("ONBOARDING_CLIENT_ID")
|
missing.append("client_id")
|
||||||
if not ONBOARDING_CLIENT_SECRET:
|
if not cfg.client_secret:
|
||||||
missing.append("ONBOARDING_CLIENT_SECRET")
|
missing.append("client_secret")
|
||||||
if not ONBOARDING_REDIRECT_URI:
|
if not cfg.redirect_uri:
|
||||||
missing.append("ONBOARDING_REDIRECT_URI")
|
missing.append("redirect_uri")
|
||||||
if missing:
|
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"
|
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||||
data = {
|
data = {
|
||||||
"client_id": ONBOARDING_CLIENT_ID,
|
"client_id": cfg.client_id,
|
||||||
"client_secret": ONBOARDING_CLIENT_SECRET,
|
"client_secret": cfg.client_secret,
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"scope": "https://graph.microsoft.com/.default",
|
"scope": "https://graph.microsoft.com/.default",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,3 +158,19 @@ class CreateScanAppResponse(BaseModel):
|
|||||||
app_object_id: str
|
app_object_id: str
|
||||||
service_principal_id: str
|
service_principal_id: str
|
||||||
display_name: 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
|
||||||
|
|||||||
71
containers/clearview/src/clearview_app/settings_service.py
Normal file
71
containers/clearview/src/clearview_app/settings_service.py
Normal 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()
|
||||||
@ -7,7 +7,7 @@ history, so operators can see exactly which image build is running.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
VERSION = "v0.2.0"
|
VERSION = "v0.2.0"
|
||||||
BUILD = 1
|
BUILD = 2
|
||||||
|
|
||||||
|
|
||||||
def display_version() -> str:
|
def display_version() -> str:
|
||||||
|
|||||||
@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
This file documents changes on the develop branch of this project.
|
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
|
## 2026-06-19 — Stack: no silent defaults + per-environment naming
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
Reference in New Issue
Block a user