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

@@ -118,6 +118,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
mciasv1.RegisterAccountServiceServer(srv, &accountServiceServer{s: s})
mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s})
mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s})
mciasv1.RegisterSSOClientServiceServer(srv, &ssoClientServiceServer{s: s})
return srv
}

View File

@@ -0,0 +1,187 @@
// ssoclientservice implements mciasv1.SSOClientServiceServer.
// All handlers are admin-only and delegate to the same db package used by
// the REST SSO client handlers in internal/server/handlers_sso_clients.go.
package grpcserver
import (
"context"
"errors"
"fmt"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
type ssoClientServiceServer struct {
mciasv1.UnimplementedSSOClientServiceServer
s *Server
}
func ssoClientToProto(c *model.SSOClient) *mciasv1.SSOClient {
return &mciasv1.SSOClient{
ClientId: c.ClientID,
RedirectUri: c.RedirectURI,
Tags: c.Tags,
Enabled: c.Enabled,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func (ss *ssoClientServiceServer) ListSSOClients(ctx context.Context, _ *mciasv1.ListSSOClientsRequest) (*mciasv1.ListSSOClientsResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
clients, err := ss.s.db.ListSSOClients()
if err != nil {
ss.s.logger.Error("list SSO clients", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
resp := &mciasv1.ListSSOClientsResponse{
Clients: make([]*mciasv1.SSOClient, 0, len(clients)),
}
for _, c := range clients {
resp.Clients = append(resp.Clients, ssoClientToProto(c))
}
return resp, nil
}
func (ss *ssoClientServiceServer) CreateSSOClient(ctx context.Context, req *mciasv1.CreateSSOClientRequest) (*mciasv1.CreateSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
if req.RedirectUri == "" {
return nil, status.Error(codes.InvalidArgument, "redirect_uri is required")
}
claims := claimsFromContext(ctx)
var createdBy *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
createdBy = &actor.ID
}
}
c, err := ss.s.db.CreateSSOClient(req.ClientId, req.RedirectUri, req.Tags, createdBy)
if err != nil {
ss.s.logger.Error("create SSO client", "error", err)
return nil, status.Error(codes.InvalidArgument, err.Error())
}
ss.s.db.WriteAuditEvent(model.EventSSOClientCreated, createdBy, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, c.ClientID))
return &mciasv1.CreateSSOClientResponse{Client: ssoClientToProto(c)}, nil
}
func (ss *ssoClientServiceServer) GetSSOClient(ctx context.Context, req *mciasv1.GetSSOClientRequest) (*mciasv1.GetSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
c, err := ss.s.db.GetSSOClient(req.ClientId)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("get SSO client", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
return &mciasv1.GetSSOClientResponse{Client: ssoClientToProto(c)}, nil
}
func (ss *ssoClientServiceServer) UpdateSSOClient(ctx context.Context, req *mciasv1.UpdateSSOClientRequest) (*mciasv1.UpdateSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
var redirectURI *string
if req.RedirectUri != nil {
v := req.GetRedirectUri()
redirectURI = &v
}
var tags *[]string
if req.UpdateTags {
t := req.Tags
tags = &t
}
var enabled *bool
if req.Enabled != nil {
v := req.GetEnabled()
enabled = &v
}
err := ss.s.db.UpdateSSOClient(req.ClientId, redirectURI, tags, enabled)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("update SSO client", "error", err)
return nil, status.Error(codes.InvalidArgument, err.Error())
}
claims := claimsFromContext(ctx)
var actorID *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
ss.s.db.WriteAuditEvent(model.EventSSOClientUpdated, actorID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, req.ClientId))
updated, err := ss.s.db.GetSSOClient(req.ClientId)
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
return &mciasv1.UpdateSSOClientResponse{Client: ssoClientToProto(updated)}, nil
}
func (ss *ssoClientServiceServer) DeleteSSOClient(ctx context.Context, req *mciasv1.DeleteSSOClientRequest) (*mciasv1.DeleteSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
err := ss.s.db.DeleteSSOClient(req.ClientId)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("delete SSO client", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
claims := claimsFromContext(ctx)
var actorID *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
ss.s.db.WriteAuditEvent(model.EventSSOClientDeleted, actorID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, req.ClientId))
return &mciasv1.DeleteSSOClientResponse{}, nil
}