Move SSO clients from config to database

- Add sso_clients table (migration 000010) with client_id, redirect_uri,
  tags (JSON), enabled flag, and audit timestamps
- Add SSOClient model struct and audit events
- Implement DB CRUD with 10 unit tests
- Add REST API: GET/POST/PATCH/DELETE /v1/sso/clients (policy-gated)
- Add gRPC SSOClientService with 5 RPCs (admin-only)
- Add mciasctl sso list/create/get/update/delete commands
- Add web UI admin page at /sso-clients with HTMX create/toggle/delete
- Migrate handleSSOAuthorize and handleSSOTokenExchange to use DB
- Remove SSOConfig, SSOClient struct, lookup methods from config
- Simplify: client_id = service_name for policy evaluation

Security:
- SSO client CRUD is admin-only (policy-gated REST, requireAdmin gRPC)
- redirect_uri must use https:// (validated at DB layer)
- Disabled clients are rejected at both authorize and token exchange
- All mutations write audit events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 23:47:53 -07:00
parent 4430ce38a4
commit df7773229c
24 changed files with 2284 additions and 217 deletions

View File

@@ -15,6 +15,7 @@
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
<li><a href="/audit">Audit</a></li>
<li><a href="/policies">Policies</a></li>
<li><a href="/sso-clients">SSO Clients</a></li>
<li><a href="/pgcreds">PG Creds</a></li>{{else}}<li><a href="/service-accounts">Service Accounts</a></li>{{end}}
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>

View File

@@ -0,0 +1,31 @@
{{define "sso_client_row"}}
<tr id="sso-client-row-{{.ClientID}}">
<td><strong>{{.ClientID}}</strong></td>
<td><code style="font-size:0.85em">{{.RedirectURI}}</code></td>
<td>{{range .Tags}}<span class="badge">{{.}}</span> {{end}}</td>
<td>
{{if .Enabled}}<span class="badge badge-active">enabled</span>
{{else}}<span class="badge badge-inactive">disabled</span>{{end}}
</td>
<td class="d-flex gap-1">
{{if .Enabled}}
<button class="btn btn-sm btn-secondary"
hx-patch="/sso-clients/{{.ClientID}}/toggle"
hx-vals='{"enabled":"false"}'
hx-target="#sso-client-row-{{.ClientID}}"
hx-swap="outerHTML">Disable</button>
{{else}}
<button class="btn btn-sm btn-primary"
hx-patch="/sso-clients/{{.ClientID}}/toggle"
hx-vals='{"enabled":"true"}'
hx-target="#sso-client-row-{{.ClientID}}"
hx-swap="outerHTML">Enable</button>
{{end}}
<button class="btn btn-sm btn-danger"
hx-delete="/sso-clients/{{.ClientID}}"
hx-target="#sso-client-row-{{.ClientID}}"
hx-swap="outerHTML"
hx-confirm="Delete SSO client '{{.ClientID}}'? This cannot be undone.">Delete</button>
</td>
</tr>
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "title"}} - SSO Clients{{end}}
{{define "content"}}
<div class="page-header d-flex justify-between align-center">
<div>
<h2>SSO Clients</h2>
<p class="text-muted text-small">Registered applications that use MCIAS for single sign-on.</p>
</div>
<button class="btn btn-primary" onclick="var f=document.getElementById('create-form');f.hidden=!f.hidden;this.textContent=f.hidden?'Add Client':'Cancel'">Add Client</button>
</div>
<div id="create-form" class="card mt-2" hidden>
<div class="card-title">Register SSO Client</div>
<form hx-post="/sso-clients" hx-target="#sso-clients-tbody" hx-swap="afterbegin">
<div class="d-flex gap-1" style="flex-wrap:wrap">
<div class="form-group" style="flex:1;min-width:200px">
<label class="form-label">Client ID / Service Name</label>
<input class="form-control" type="text" name="client_id" required placeholder="e.g. mcr">
</div>
<div class="form-group" style="flex:2;min-width:300px">
<label class="form-label">Redirect URI</label>
<input class="form-control" type="url" name="redirect_uri" required placeholder="https://service.example.com/sso/callback">
</div>
<div class="form-group" style="flex:1;min-width:200px">
<label class="form-label">Tags <span class="text-muted text-small">(comma-separated)</span></label>
<input class="form-control" type="text" name="tags" placeholder="env:prod,tier:web">
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Create</button>
</div>
</form>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Client ID</th>
<th>Redirect URI</th>
<th>Tags</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="sso-clients-tbody">
{{range .Clients}}
{{template "sso_client_row" .}}
{{end}}
</tbody>
</table>
{{if not .Clients}}<p class="text-muted" style="text-align:center;padding:2rem">No SSO clients registered.</p>{{end}}
</div>
{{end}}