Skip to content

Expose safe site settings in Studio and the admin API #114

Description

@alexeygrigorev

Parent: #62

Outcome

Deliver the first safe site-configuration vertical slice: an authorized operator can inspect and atomically update two code-owned announcement settings from Studio or the admin API, and the public Django shell reflects the committed value on the next request in every process. Both management adapters use one query service and one batch command service; neither adapter owns validation, persistence, concurrency, idempotency, or audit behavior.

This slice deliberately adapts the registry/Studio/API shape from AI Shipping Labs while preserving this repository's architecture and security rules. It must not copy or introduce that project's plaintext/database-secret behavior.

Reviewed baseline: 5b6a8418f105f7cbdf7d3821d5a3c4f0a4f4c613 on main.

Normative authority

Where this issue conflicts with the AI Shipping Labs reference, the DTC specifications and the exact contract below win.

Dependencies and delivery sequence

  1. The code prerequisites from Add shared service, configuration, operation, and durable-job primitives #31, Add the decision-free Studio authorization and audit foundation #86, Build the decision-free admin API and service-principal foundation #87, and Enable development owner login, Studio access, and management API token issuance #107 are present on main; the operator's real login/token use from Enable development owner login, Studio access, and management API token issuance #107 is not required to implement or test this issue.
  2. Complete Make pinned Rustkyll source builds byte-reproducible #118, then refresh, verify, accept, and land Make Studio → Courses the canonical course-operations surface #115. Make Studio → Courses the canonical course-operations surface #115 changes shared Studio navigation and role-support surfaces. Expose safe site settings in Studio and the admin API #114 must branch from that resulting main and preserve the canonical /studio/courses work rather than racing or reconstructing it.
  3. Implement and verify Expose safe site settings in Studio and the admin API #114 through _docs/PROCESS.md: engineer without committing, independent tester with safe screenshots, PM acceptance, then the lifecycle-owned commit/local merge. No pull request is required.
  4. After deployment, the development-owner bootstrap/reconciliation grants the named development automation service principal the new settings permissions. The owner explicitly creates a new scoped token through Enable development owner login, Studio access, and management API token issuance #107. Existing credentials do not acquire new scopes, and no raw token is lifecycle evidence.

#20, #28, #32, #33, and #61 are production identity/high-risk-policy follow-ups, not blockers: this issue contains only public-safe values and no secret or high-risk operation. Future positive edge caching may require additional invalidation work under its owning issue; this slice does not silently decide it.

Scope

Code-owned operational-setting registry

Extend the existing core.configuration.OperationalSettingDefinition contract rather than creating a second registry. A definition has these required, immutable, code-owned fields:

Field Contract
key Stable lowercase identifier; unique; never user-created.
group Stable lowercase grouping identifier used for deterministic ordering.
label Human-readable, non-empty field label.
description Human-readable operational consequence/help text.
value_type One of the existing supported safe JSON value types.
default Validated code default used when no row exists.
version Positive definition version exposed as definition_version.
validation Bounded, serializable, non-secret constraint metadata for adapters and schema generation; enforcement remains server-side.
validator Server-side validator/normalizer hook; never serialized as code or a callable representation.
docs_reference Repository-relative authoritative help/reference string.
lifecycle Exact code-owned state; this slice supports active.
cache_policy Exact code-owned state; both definitions use uncached.
sensitivity Exact code-owned state; both definitions use public.

Startup/system checks reject duplicate or incomplete definitions, unsupported type/lifecycle/cache/sensitivity values, invalid defaults or validation metadata, and any secret-bearing key/default/metadata. Registration remains in application code. Neither the registry nor either management adapter may enumerate or mutate Django settings, environment variables, deployment configuration, credential material, or arbitrary OperationalSetting rows.

Register exactly these two definitions, in deterministic key order:

Key Definition
site.announcement.enabled group=site.announcement; label Show site announcement; boolean; default false; version 1; active; uncached; public.
site.announcement.message group=site.announcement; label Announcement message; string; default ""; version 1; active; uncached; public; trim leading/trailing Unicode whitespace before storage; empty is allowed; otherwise at most 500 Unicode code points; single-line only; reject NUL/control characters and < or > rather than accepting markup.

Both descriptions must explain that the banner appears only when enabled and the trimmed message is non-empty. Both use a repository-relative reference to this issue/specification. Studio help text warns that the message is public and must not contain secrets, credentials, private links, personal data, or HTML. Template auto-escaping remains mandatory even though markup-shaped input is rejected.

Shared query and batch command services

Add one settings query service and one settings batch command service in the owning application layer. Studio, the admin API, and public presentation may format their own response/view models, but they do not read or write OperationalSetting directly.

The query service:

  • returns only registered active/public definitions in stable key order;
  • resolves an absent row to the validated code default with source=code_default and revision=0;
  • returns each definition's safe metadata plus current value, source, definition_version, and revision;
  • fetches all registered rows in one bounded database query rather than one query per key; and
  • treats an unknown/unregistered database row as non-enumerable, not as a new setting.

The batch command accepts one top-level updates list containing one or two unique items. Each item contains exactly key, value, and integer expected_revision; unknown/extra fields, unknown keys, duplicate keys, booleans masquerading as integer revisions, negative revisions, wrong types, overlong/unsafe messages, an empty list, and more than two items fail before any setting mutation. The adapter supplies the authenticated actor/context, an adapter-owned source (studio or admin_api), and the idempotency key; clients cannot mass-assign them.

Before writing, the service normalizes and validates the complete batch. In one database transaction it locks/compares submitted keys in sorted order, checks every expected revision, and then applies all changes or none. A missing row has revision 0; its first changed override becomes revision 1. A changed value advances that key by exactly one and creates one append-only OperationalSettingRevision. An unchanged normalized value is returned with changed=false, keeps its revision, and creates no setting revision. CAS still checks unchanged submitted items so the form cannot unknowingly save over a concurrent edit.

The command is linearizable for overlapping keys. A stale item makes the entire batch a safe 409 with the conflicting key/current revision and no setting/revision write. Concurrent overlapping batches produce one winner and one stale/conflict result; disjoint batches may both succeed. Lock ordering must avoid inverse-order deadlocks. SQLite covers normal behavior; PostgreSQL separate-connection/barrier tests prove missing-row creation, overlap, and multi-key atomicity.

Idempotency is part of the shared command contract, not adapter-specific mutation logic:

  • it is required for admin API POST and supplied by Studio as a generated hidden UUID retained across validation/stale redisplay;
  • it is scoped by authenticated actor/principal plus site.settings.write, stores only a hash of the raw key, hashes the canonical normalized command, and uses the existing bounded persistence primitive;
  • an identical replay returns the first safe result with replayed=true and produces no second mutation, revision, or audit event;
  • reuse by the same actor with a different normalized command is a safe 409; reuse by another actor cannot replay or reveal the first result; and
  • concurrent identical first submissions converge on one committed result. No in-progress or failed record may strand a partial settings mutation.

One successful changed batch creates one append-only audit event and links all revisions created by that batch to it. Audit metadata contains actor/principal snapshot, adapter source, request/correlation/idempotency references, affected keys, before/after revisions, and outcome, but never the message/default values, request body, authorization header, cookie, raw idempotency key, token, email, or other secret/PII. An all-no-op batch and an idempotent replay create no duplicate revision/change audit. Existing append-only model and PostgreSQL guards remain intact.

Capabilities, permissions, and role mapping

Register two real, non-test capabilities in the one neutral management registry:

Capability Service Django permission Studio adapter Admin API adapter Policies
site.settings.read query core.read_operational_settings GET, route name studio:settings, /studio/settings GET /api/v1/admin/settings, scope site.settings.read idempotency none; concurrency none; read rate class/cost 1; safe query audit metadata
site.settings.write command core.change_operational_settings POST, route name studio:settings, /studio/settings POST /api/v1/admin/settings, scope site.settings.write idempotency required; revision concurrency; write rate class/cost 1; core.operational_settings.batch_updated audit action; redact value, default, updates, authorization/cookies/tokens/body

Use distinct operation IDs and declared request/result schemas. Registry checks must cover the service, permission, routes/methods, scopes, writable fields, rate metadata, idempotency/concurrency, audit/redaction, and parity factory. Do not add a second route or capability registry.

Update the exact code-owned role manifest and migration-backed permissions:

Principal/role Read Write
site_admin yes yes
content_operator yes yes
auditor yes no
course_operator, event_operator, email_operator, support_operator no no
development automation service principal from #107 yes yes, after idempotent development bootstrap/reconciliation

Every request still requires the full #86/#87 authority intersection. is_staff, is_superuser, group name, token scope, snapshot, or a visible navigation link never grants authority by itself. Removing a group/permission, disabling the user/principal, revoking the session/credential, or revoking the credential takes effect on the next request. The settings link is capability-filtered and appears in the Site Studio section only for readers. Writers without read authority are invalid registry/role configurations and fail checks.

The development automation principal gains permission only through the idempotent development-only reconciliation owned by this issue. It receives no plaintext token. Existing tokens retain their immutable scopes; the owner must explicitly issue a new expiring token with site.settings.read and/or site.settings.write through #107.

Studio contract

Add canonical /studio/settings (no trailing slash) with the route name studio:settings. No compatibility route exists or is required.

GET shows one Site announcement fieldset containing both registered definitions, help text, type/default, current source (Code default or safe database-source label), and current revision. A read-only actor sees the values and no save control. An authorized writer gets native controls: one checkbox and one single-line text input with the exact 500-character bound. The HTML contains a CSRF token, hidden expected revisions, and a hidden idempotency UUID.

POST calls the shared batch command and uses Post/Redirect/Get after success so refresh/back cannot resubmit. Invalid input returns 400; stale revision returns 409. Both redisplay an accessible error summary linked to field errors, preserve the submitted value and idempotency key, show the now-current safe revision/source separately, and move focus to the summary. Missing/invalid CSRF is rejected before the service. Anonymous, invalid/revoked-session, read-only, and permission-removed cases use the existing safe Studio authentication/403 behavior.

All Studio success, redirect, validation, denial, and error responses preserve Cache-Control: private, no-store and exact X-Robots-Tag: noindex, nofollow. The page uses the existing readable Studio template conventions, works without JavaScript, has semantic headings/fieldset/legend/labels/help/errors, visible focus, keyboard operation, and no horizontal overflow at the required viewports.

Admin API contract

Add exactly:

  • GET /api/v1/admin/settings requiring Bearer scope/capability site.settings.read.
  • POST /api/v1/admin/settings requiring Bearer scope/capability site.settings.write, UTF-8 application/json, and one valid Idempotency-Key header.

GET returns status 200 and this envelope in stable key order:

{
  "settings": [
    {
      "key": "site.announcement.enabled",
      "group": "site.announcement",
      "label": "Show site announcement",
      "description": "...",
      "value_type": "boolean",
      "default": false,
      "validation": {},
      "docs_reference": "...",
      "lifecycle": "active",
      "cache_policy": "uncached",
      "sensitivity": "public",
      "value": false,
      "source": "code_default",
      "definition_version": 1,
      "revision": 0
    }
  ]
}

POST accepts only:

{
  "updates": [
    {"key": "site.announcement.enabled", "value": true, "expected_revision": 0},
    {"key": "site.announcement.message", "value": "Office hours start at 18:00.", "expected_revision": 0}
  ]
}

It returns status 200, replayed, and the same safe resolved representation for submitted keys plus changed. It does not accept source, actor/principal, revision/definition metadata, sensitivity, lifecycle, cache, or arbitrary setting fields from the client. It uses the existing strict body size/depth/node/duplicate-key parser, central JSON envelope, generic Bearer failures, CORS deny-by-default, rate limiting, and private/no-store/noindex policy. Exact failures are 400 for shape/type/validation/unknown or duplicate setting keys, 401 for indistinguishable credential failures, 403 for authority failure before service execution, 409 for stale CAS or idempotency replay conflict/in-progress state, 413 for body bounds, 415 for media type/encoding, and 429 for rate admission. No Django HTML/debug response may escape admin-v1.

Public announcement contract and cross-process visibility

All public views extending the unified templates/base.html shell receive one small public-announcement view model from the shared settings query layer. Render one semantic <aside aria-label="Site announcement"> after the site header and before <main> only when enabled is true and the normalized message is non-empty. Render the message once as escaped plain text; do not use safe, HTML interpolation, Markdown, links, scripts, images, tracking, user-specific targeting, or role=alert. When disabled/empty, emit no banner wrapper or blank landmark.

This slice makes an explicit no-cache decision:

  • the definition registry is immutable process-local code, but resolved database values are never memoized in process, Django cache, task state, or a worker singleton;
  • every management query and every public-shell request performs one bounded database resolution for both keys;
  • the command commits before returning success, so every web/worker process observes the accepted value on its next query/request without restart, pub/sub, polling loop, durable job, or cache-invalidation network side effect; and
  • if public resolution alone encounters unavailable/corrupt persisted configuration, the public page remains available and fails closed to no announcement with a bounded secret-free operational error. Studio/admin API resolution surfaces the existing safe 500 contract instead of pretending a save/read succeeded. Deployment/system checks still fail on invalid code-owned registry metadata.

Current public responses are not given a new positive edge-cache TTL by this issue. A future caching owner must classify announcement-dependent pages and add write-triggered invalidation before enabling positive shared caching. This issue does not claim CDN invalidation it does not implement.

The browser contract covers pages that already extend templates/base.html (at minimum / and /events/). Course-shell unification remains with #115/#59; this issue must not edit a separate legacy course shell just to duplicate the banner.

Privacy, security, and accessibility

  • Only sensitivity=public definitions can enter these services. The two values are intended for public rendering, but UI/API documentation still prohibits secrets, credentials, private URLs, personal data, or registration data.
  • No setting name/value is read from production data for development, and no production user, password hash, session, token, provider row, or database is imported by this issue.
  • Registry checks and adapter allowlists prevent access to secret-bearing names, unregistered rows, Django settings, environment variables, deployment/provider material, and arbitrary database configuration.
  • Server-side type/length/control/markup validation and template auto-escaping provide defense in depth. Invalid markup never mutates either key; no reflected input appears unescaped in HTML/errors/logs.
  • Authentication/authorization happens before parsing target keys into service work. Generic denials do not reveal credential/principal state. CSRF protects Studio; strict Bearer scope, current permission, bounded JSON, rate limits, no CORS, no-store/noindex, and safe errors protect admin-v1.
  • Logs, audit, exceptions, idempotency rows, screenshots, traces, OpenAPI examples, and issue reports contain no authorization header, token, cookie, session key, password/digest, email, raw idempotency key, production data, or message canary designated as private.
  • Studio and the public banner meet WCAG 2.2 AA expectations: semantic structure, programmatic labels/help/errors, error-summary focus, visible focus, keyboard-only operation, readable contrast, 200% zoom/reflow, and no horizontal scrolling at 320 CSS pixels.

Migration, audit, OpenAPI, and parity deliverables

  • Add only the migration required to declare core.read_operational_settings and core.change_operational_settings and update deterministic role synchronization. Do not create default setting rows or run a data migration; absent rows are the code defaults.
  • Preserve existing OperationalSetting and append-only OperationalSettingRevision ownership/constraints unless a schema change is strictly required by the shared batch contract. Any such change must be additive, justified, migrated, and tested on SQLite and PostgreSQL.
  • Register both capabilities in the neutral registry and update the existing Studio navigation. The owning services stay outside studio and management_api.
  • Generate admin-v1 OpenAPI 3.1 through management_api/openapi.py and update _docs/api/admin-openapi.json. Do not edit or merge this with legacy api/openapi/spec.py or /api/openapi.json.
  • OpenAPI declares Bearer security, exact GET/POST schemas/statuses/errors, the required Idempotency-Key header on POST, bounds/enums, and no secret or arbitrary-setting schema.
  • Bidirectional checks prove runtime route ↔ capability ↔ service ↔ permission/policies ↔ schema/OpenAPI ↔ idempotency/concurrency/rate/audit/result metadata. A registered operation missing a runtime route/schema and a runtime/schema operation missing a capability both fail.
  • Cross-adapter parity tests send equivalent commands through Studio and admin-v1 and compare normalized validation, authorization, DB effects, revisions, safe result fields, audit action/redaction, stale behavior, idempotent replay, and public visibility. Adapter-specific HTML/JSON, actor/source, CSRF/Bearer, and redirect fields are the only expected differences.

Required Django, database, contract, and security scenarios

  1. Registry tests cover the two exact definitions/order/metadata/defaults and reject duplicate keys, incomplete labels/descriptions, invalid group/docs/version/lifecycle/cache/sensitivity/validation, invalid default/type, secret-bearing keys/nested metadata, unsupported types, and conflicting re-registration.
  2. Query tests cover both defaults, one/two overrides, source/revision/version, deterministic order, one bounded DB query, ignored unregistered rows, malformed stored type/value, database failure behavior, and consecutive reads observing a separately committed change with no process cache.
  3. Command validation tests cover each valid value, Unicode/trim/empty/max boundary, wrong boolean/string/revision types, negative revision, overlength, newline/tab/NUL/control, </>, unknown/extra/missing fields, unknown/duplicate keys, empty/oversized batch, client-supplied source/actor/revision metadata, and all-or-none behavior when the second item is invalid.
  4. CAS/revision tests cover revision-0 create, update, mixed create/update, unchanged items, stale first/second item, stale unchanged item, rollback after an injected second-write/revision/audit failure, stable source/version, exactly one revision per changed key, one linked redacted audit event, append-only enforcement, and no audit/idempotency value leakage.
  5. Idempotency tests cover first success, identical replay, canonical normalization replay, changed-payload conflict, same raw key for a second actor, all-no-op result, rollback/crash, and no duplicate settings/revisions/audits. PostgreSQL barriers cover identical first submission, absent-row create/create, overlapping one-key and two-key batches, inverse input order, and disjoint batches.
  6. Role/capability tests synchronize twice and assert the exact matrix above. Exercise positive paths and anonymous, inactive, non-staff, unassigned staff, superuser-only, wrong role, read-only auditor POST, missing scope, scope-without-permission, permission-without-scope, disabled principal/user, revoked session/token, expired/rotated credential, removed permission, malformed token, and old token without new scope.
  7. Studio tests cover GET/HEAD/POST/method denial, canonical slashless route, capability-filtered navigation, read-only view, CSRF, PRG, validation 400, stale 409, retained form/idempotency state, error summary/focus hook, permission removal on next request, private/no-store/noindex on every response class, and zero direct model mutation from the view.
  8. Admin API tests cover exact GET/POST happy paths and schemas; strict JSON/media/encoding/body/depth/node/duplicate-key/extra-field bounds; idempotency header missing/repeated/invalid/replay/conflict; generic 401/403; stale 409; rate 429; CORS/preflight/method denial; safe 500; private/no-store/noindex; and absence of Django HTML/debug/secret material.
  9. Public tests cover disabled default, enabled+empty, enabled+message, immediate next-request enable/change/disable, separate DB connections/process-equivalent reads, homepage and events shell placement, one banner only, semantic label, no empty wrapper, escaped quotes/ampersands, rejected markup/control attempts with previous value intact, public DB-read fail-closed behavior, and no Studio banner leakage into the separate private shell.
  10. OpenAPI/parity tests regenerate the checked-in admin-v1 artifact, validate OpenAPI 3.1, compare runtime operations both directions, prove Bearer/idempotency/error/bounds schemas, prove legacy OpenAPI is untouched, and run the equivalent Studio/API service-result and database-effect matrix.
  11. Run the relevant uv-backed format, Ruff, mypy, migration-drift, Django/deployment checks, focused SQLite tests, PostgreSQL concurrency/append-only tests, full affected Django/adoption/compatibility suites, OpenAPI/parity checks, and core Playwright target. Tests use isolated test databases, never the normal development database.

Explicit desktop and mobile browser acceptance

Use deterministic local/test data only. Capture screenshots beneath .tmp/screenshots/issue-114/; never enable tracing/video/network dumps while a real Bearer token is present and never put a token or production message/data in an artifact.

  1. At 1440x900 and 390x844, sign in as site_admin, open /studio/settings, and verify the Site navigation state, heading/fieldset, labels/help/default/source/revision, checkbox/text control, 500-character affordance, visible focus, keyboard-only tab/save flow, readable errors, reflow, and no horizontal overflow.
  2. At both viewports, save an enabled deterministic message, follow PRG, then open / and /events/ and verify one banner appears after the header/before main with exact escaped plain text, semantic label, readable wrapping, zoom/reflow, and no layout shift/overflow. Change the message and then disable it; each next public navigation reflects the commit without process restart and disabled pages contain no empty banner landmark.
  3. With two authenticated browser contexts, load the same revisions, save in the first, then submit the stale second form. Verify 409, a focused error summary, preserved proposed input, separately displayed current revision/source, no partial setting change, and a successful retry with a retained/regenerated safe idempotency key as designed.
  4. At both viewports, verify an auditor can read but cannot save, an unrelated role cannot see the Site/settings navigation or page, logout/session revocation/removing permission denies on the next navigation, and browser back/reload does not recover a private cached management page.
  5. Attempt the deterministic markup/control/overlength canaries through Studio. Verify safe field errors, escaped non-reflective content, no public change, no debug page, and no canary in screenshot/log/audit/idempotency evidence. Screenshots show only deterministic public-safe text.

Non-goals

  • No arbitrary site-settings CRUD, database-authored definitions, environment/Django-setting editor, feature-flag platform, navigation management, sponsor management, content publishing workflow, email/course/event behavior, or public personalization.
  • No secret, credential, token, password, provider, webhook, private URL, signing/encryption key, or other sensitive setting in the registry/database/UI/API. No adaptation of AI Shipping Labs plaintext-secret behavior.
  • No HTML, Markdown, rich text, links, images, scripts, tracking, scheduling, audience targeting, multiple announcements, dismiss state/cookie, or translation workflow for the banner.
  • No /podwiki to /wiki redirect, no compatibility redirects of any kind, and no redirect analytics. This is a new feature and no redirect work is required.
  • No route/navigation rewrite owned by Make Studio → Courses the canonical course-operations surface #115, no duplicate banner in a legacy course shell, and no change to Django admin /admin/ or its purpose.
  • No production OIDC/MFA/break-glass/high-risk policy decision, no production database/account import, no automatic token issuance, no wildcard/future scope, and no storage or reporting of a raw token.
  • No positive CDN/shared-cache TTL, pub/sub invalidation, polling worker, restart hook, or network side effect. Future caching work must add its own correct invalidation contract before changing this decision.

Acceptance criteria

  • The exact registry schema and two announcement definitions are code-owned, startup-checked, public-safe, deterministic, and incapable of exposing arbitrary/secret settings.
  • One shared query and one shared batch command implement validation/normalization, all-or-none CAS, actor-scoped idempotency, revisions, and redacted audit; Studio/API contain no business mutation.
  • The exact capability/permission/role/service-principal mapping is synchronized and enforced on every request; existing credentials gain no implicit scope.
  • /studio/settings and GET/POST /api/v1/admin/settings meet the exact contracts, security headers, error behavior, OpenAPI, and bidirectional parity requirements.
  • The unified public shell renders only the enabled non-empty escaped announcement and observes committed writes on the next request across processes with the explicit uncached/fail-closed decision.
  • Required migrations, audit/redaction, negative/security/accessibility tests, PostgreSQL concurrency tests, and desktop/mobile browser scenarios pass with safe artifacts.
  • All required uv-backed repository checks pass, an independent tester verifies the implementation/screenshots, and PM acceptance occurs before lifecycle-owned commit/merge.

Source adaptation reference

The owner-requested pattern reference is ../ai-shipping-labs/integrations/settings_registry.py, ../ai-shipping-labs/integrations/config.py, ../ai-shipping-labs/studio/views/settings.py, and ../ai-shipping-labs/api/views/integration_settings.py: its code-owned setting registry plus shared Studio/admin-API adapter shape. Treat it as a structural reference only. Copy no credentials, values, environment behavior, database data, secret fields, plaintext-secret import/export or storage, token behavior, branding, or product-domain policy. If the external source cannot be reviewed during implementation, this issue remains complete authority and no network dependency is introduced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Must-have or release-blockingadminArea: adminenhancementNew feature or requestfrontendArea: frontendoperationsArea: operationssecurityArea: securitytestingArea: testing

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions