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:
187
internal/grpcserver/ssoclientservice.go
Normal file
187
internal/grpcserver/ssoclientservice.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user