From df7773229c11d0346540362d5bb50252f6118af8 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 31 Mar 2026 23:47:53 -0700 Subject: [PATCH] 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) --- cmd/mciasctl/main.go | 155 ++++ gen/mcias/v1/sso_client.pb.go | 703 ++++++++++++++++++ gen/mcias/v1/sso_client_grpc.pb.go | 289 +++++++ internal/config/config.go | 57 -- internal/config/config_test.go | 147 ---- internal/db/migrate.go | 2 +- .../db/migrations/000010_sso_clients.up.sql | 10 + internal/db/sso_clients.go | 206 +++++ internal/db/sso_clients_test.go | 192 +++++ internal/grpcserver/grpcserver.go | 1 + internal/grpcserver/ssoclientservice.go | 187 +++++ internal/model/model.go | 18 + internal/policy/policy.go | 7 +- internal/server/handlers_sso.go | 16 +- internal/server/handlers_sso_clients.go | 175 +++++ internal/server/server.go | 12 + internal/ui/handlers_sso.go | 12 +- internal/ui/handlers_sso_clients.go | 129 ++++ internal/ui/ui.go | 10 + proto/generate.go | 2 +- proto/mcias/v1/sso_client.proto | 86 +++ web/templates/base.html | 1 + web/templates/fragments/sso_client_row.html | 31 + web/templates/sso_clients.html | 53 ++ 24 files changed, 2284 insertions(+), 217 deletions(-) create mode 100644 gen/mcias/v1/sso_client.pb.go create mode 100644 gen/mcias/v1/sso_client_grpc.pb.go create mode 100644 internal/db/migrations/000010_sso_clients.up.sql create mode 100644 internal/db/sso_clients.go create mode 100644 internal/db/sso_clients_test.go create mode 100644 internal/grpcserver/ssoclientservice.go create mode 100644 internal/server/handlers_sso_clients.go create mode 100644 internal/ui/handlers_sso_clients.go create mode 100644 proto/mcias/v1/sso_client.proto create mode 100644 web/templates/fragments/sso_client_row.html create mode 100644 web/templates/sso_clients.html diff --git a/cmd/mciasctl/main.go b/cmd/mciasctl/main.go index 513d072..364b3fa 100644 --- a/cmd/mciasctl/main.go +++ b/cmd/mciasctl/main.go @@ -88,6 +88,7 @@ func main() { root.AddCommand(pgcredsCmd()) root.AddCommand(policyCmd()) root.AddCommand(tagCmd()) + root.AddCommand(ssoCmd()) if err := root.Execute(); err != nil { os.Exit(1) @@ -956,6 +957,160 @@ func tagSetCmd() *cobra.Command { return cmd } +// ---- SSO client commands ---- + +func ssoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sso", + Short: "SSO client management commands", + } + cmd.AddCommand(ssoListCmd()) + cmd.AddCommand(ssoCreateCmd()) + cmd.AddCommand(ssoGetCmd()) + cmd.AddCommand(ssoUpdateCmd()) + cmd.AddCommand(ssoDeleteCmd()) + return cmd +} + +func ssoListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all SSO clients", + Run: func(cmd *cobra.Command, args []string) { + c := newController() + var result json.RawMessage + c.doRequest("GET", "/v1/sso/clients", nil, &result) + printJSON(result) + }, + } +} + +func ssoCreateCmd() *cobra.Command { + var clientID, redirectURI, tagsFlag string + + cmd := &cobra.Command{ + Use: "create", + Short: "Register a new SSO client", + Run: func(cmd *cobra.Command, args []string) { + if clientID == "" { + fatalf("sso create: --client-id is required") + } + if redirectURI == "" { + fatalf("sso create: --redirect-uri is required") + } + + c := newController() + + body := map[string]interface{}{ + "client_id": clientID, + "redirect_uri": redirectURI, + } + if tagsFlag != "" { + tags := []string{} + for _, t := range strings.Split(tagsFlag, ",") { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + body["tags"] = tags + } + + var result json.RawMessage + c.doRequest("POST", "/v1/sso/clients", body, &result) + printJSON(result) + }, + } + cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier / service name (required)") + cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "callback URL (https:// required)") + cmd.Flags().StringVar(&tagsFlag, "tags", "", "comma-separated list of tags") + return cmd +} + +func ssoGetCmd() *cobra.Command { + var clientID string + + cmd := &cobra.Command{ + Use: "get", + Short: "Get an SSO client by client-id", + Run: func(cmd *cobra.Command, args []string) { + if clientID == "" { + fatalf("sso get: --client-id is required") + } + c := newController() + var result json.RawMessage + c.doRequest("GET", "/v1/sso/clients/"+clientID, nil, &result) + printJSON(result) + }, + } + cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)") + return cmd +} + +func ssoUpdateCmd() *cobra.Command { + var clientID, redirectURI, tagsFlag, enabledFlag string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update an SSO client", + Run: func(cmd *cobra.Command, args []string) { + if clientID == "" { + fatalf("sso update: --client-id is required") + } + + c := newController() + body := map[string]interface{}{} + + if cmd.Flags().Changed("redirect-uri") { + body["redirect_uri"] = redirectURI + } + if cmd.Flags().Changed("tags") { + tags := []string{} + if tagsFlag != "" { + for _, t := range strings.Split(tagsFlag, ",") { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + } + body["tags"] = tags + } + if cmd.Flags().Changed("enabled") { + body["enabled"] = enabledFlag == "true" + } + + var result json.RawMessage + c.doRequest("PATCH", "/v1/sso/clients/"+clientID, body, &result) + printJSON(result) + }, + } + cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)") + cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "new callback URL") + cmd.Flags().StringVar(&tagsFlag, "tags", "", "comma-separated list of tags (empty clears)") + cmd.Flags().StringVar(&enabledFlag, "enabled", "", "true or false") + return cmd +} + +func ssoDeleteCmd() *cobra.Command { + var clientID string + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an SSO client", + Run: func(cmd *cobra.Command, args []string) { + if clientID == "" { + fatalf("sso delete: --client-id is required") + } + c := newController() + c.doRequest("DELETE", "/v1/sso/clients/"+clientID, nil, nil) + fmt.Printf("SSO client %q deleted\n", clientID) + }, + } + cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)") + return cmd +} + // ---- HTTP helpers ---- // doRequest performs an authenticated JSON HTTP request. If result is non-nil, diff --git a/gen/mcias/v1/sso_client.pb.go b/gen/mcias/v1/sso_client.pb.go new file mode 100644 index 0000000..bf71440 --- /dev/null +++ b/gen/mcias/v1/sso_client.pb.go @@ -0,0 +1,703 @@ +// SSOClientService: CRUD management of SSO client registrations. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.32.1 +// source: mcias/v1/sso_client.proto + +package mciasv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// SSOClient is the wire representation of an SSO client registration. +type SSOClient struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + RedirectUri string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"` + Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"` + Enabled bool `protobuf:"varint,4,opt,name=enabled,proto3" json:"enabled,omitempty"` + CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339 + UpdatedAt string `protobuf:"bytes,6,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // RFC3339 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSOClient) Reset() { + *x = SSOClient{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSOClient) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSOClient) ProtoMessage() {} + +func (x *SSOClient) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SSOClient.ProtoReflect.Descriptor instead. +func (*SSOClient) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{0} +} + +func (x *SSOClient) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *SSOClient) GetRedirectUri() string { + if x != nil { + return x.RedirectUri + } + return "" +} + +func (x *SSOClient) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +func (x *SSOClient) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *SSOClient) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *SSOClient) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +type ListSSOClientsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSSOClientsRequest) Reset() { + *x = ListSSOClientsRequest{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSSOClientsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSSOClientsRequest) ProtoMessage() {} + +func (x *ListSSOClientsRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSSOClientsRequest.ProtoReflect.Descriptor instead. +func (*ListSSOClientsRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{1} +} + +type ListSSOClientsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Clients []*SSOClient `protobuf:"bytes,1,rep,name=clients,proto3" json:"clients,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSSOClientsResponse) Reset() { + *x = ListSSOClientsResponse{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSSOClientsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSSOClientsResponse) ProtoMessage() {} + +func (x *ListSSOClientsResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSSOClientsResponse.ProtoReflect.Descriptor instead. +func (*ListSSOClientsResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{2} +} + +func (x *ListSSOClientsResponse) GetClients() []*SSOClient { + if x != nil { + return x.Clients + } + return nil +} + +type CreateSSOClientRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + RedirectUri string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"` + Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSSOClientRequest) Reset() { + *x = CreateSSOClientRequest{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSSOClientRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSSOClientRequest) ProtoMessage() {} + +func (x *CreateSSOClientRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSSOClientRequest.ProtoReflect.Descriptor instead. +func (*CreateSSOClientRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{3} +} + +func (x *CreateSSOClientRequest) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *CreateSSOClientRequest) GetRedirectUri() string { + if x != nil { + return x.RedirectUri + } + return "" +} + +func (x *CreateSSOClientRequest) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +type CreateSSOClientResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSSOClientResponse) Reset() { + *x = CreateSSOClientResponse{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSSOClientResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSSOClientResponse) ProtoMessage() {} + +func (x *CreateSSOClientResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSSOClientResponse.ProtoReflect.Descriptor instead. +func (*CreateSSOClientResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateSSOClientResponse) GetClient() *SSOClient { + if x != nil { + return x.Client + } + return nil +} + +type GetSSOClientRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSSOClientRequest) Reset() { + *x = GetSSOClientRequest{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSSOClientRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSSOClientRequest) ProtoMessage() {} + +func (x *GetSSOClientRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSSOClientRequest.ProtoReflect.Descriptor instead. +func (*GetSSOClientRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{5} +} + +func (x *GetSSOClientRequest) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +type GetSSOClientResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSSOClientResponse) Reset() { + *x = GetSSOClientResponse{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSSOClientResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSSOClientResponse) ProtoMessage() {} + +func (x *GetSSOClientResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSSOClientResponse.ProtoReflect.Descriptor instead. +func (*GetSSOClientResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{6} +} + +func (x *GetSSOClientResponse) GetClient() *SSOClient { + if x != nil { + return x.Client + } + return nil +} + +type UpdateSSOClientRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + RedirectUri *string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3,oneof" json:"redirect_uri,omitempty"` + Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"` + Enabled *bool `protobuf:"varint,4,opt,name=enabled,proto3,oneof" json:"enabled,omitempty"` + UpdateTags bool `protobuf:"varint,5,opt,name=update_tags,json=updateTags,proto3" json:"update_tags,omitempty"` // when true, tags field is applied (allows clearing) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateSSOClientRequest) Reset() { + *x = UpdateSSOClientRequest{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateSSOClientRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateSSOClientRequest) ProtoMessage() {} + +func (x *UpdateSSOClientRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateSSOClientRequest.ProtoReflect.Descriptor instead. +func (*UpdateSSOClientRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{7} +} + +func (x *UpdateSSOClientRequest) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *UpdateSSOClientRequest) GetRedirectUri() string { + if x != nil && x.RedirectUri != nil { + return *x.RedirectUri + } + return "" +} + +func (x *UpdateSSOClientRequest) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +func (x *UpdateSSOClientRequest) GetEnabled() bool { + if x != nil && x.Enabled != nil { + return *x.Enabled + } + return false +} + +func (x *UpdateSSOClientRequest) GetUpdateTags() bool { + if x != nil { + return x.UpdateTags + } + return false +} + +type UpdateSSOClientResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateSSOClientResponse) Reset() { + *x = UpdateSSOClientResponse{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateSSOClientResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateSSOClientResponse) ProtoMessage() {} + +func (x *UpdateSSOClientResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateSSOClientResponse.ProtoReflect.Descriptor instead. +func (*UpdateSSOClientResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{8} +} + +func (x *UpdateSSOClientResponse) GetClient() *SSOClient { + if x != nil { + return x.Client + } + return nil +} + +type DeleteSSOClientRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSSOClientRequest) Reset() { + *x = DeleteSSOClientRequest{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSSOClientRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSSOClientRequest) ProtoMessage() {} + +func (x *DeleteSSOClientRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSSOClientRequest.ProtoReflect.Descriptor instead. +func (*DeleteSSOClientRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteSSOClientRequest) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +type DeleteSSOClientResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSSOClientResponse) Reset() { + *x = DeleteSSOClientResponse{} + mi := &file_mcias_v1_sso_client_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSSOClientResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSSOClientResponse) ProtoMessage() {} + +func (x *DeleteSSOClientResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_sso_client_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSSOClientResponse.ProtoReflect.Descriptor instead. +func (*DeleteSSOClientResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{10} +} + +var File_mcias_v1_sso_client_proto protoreflect.FileDescriptor + +const file_mcias_v1_sso_client_proto_rawDesc = "" + + "\n" + + "\x19mcias/v1/sso_client.proto\x12\bmcias.v1\"\xb7\x01\n" + + "\tSSOClient\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12!\n" + + "\fredirect_uri\x18\x02 \x01(\tR\vredirectUri\x12\x12\n" + + "\x04tags\x18\x03 \x03(\tR\x04tags\x12\x18\n" + + "\aenabled\x18\x04 \x01(\bR\aenabled\x12\x1d\n" + + "\n" + + "created_at\x18\x05 \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\x06 \x01(\tR\tupdatedAt\"\x17\n" + + "\x15ListSSOClientsRequest\"G\n" + + "\x16ListSSOClientsResponse\x12-\n" + + "\aclients\x18\x01 \x03(\v2\x13.mcias.v1.SSOClientR\aclients\"l\n" + + "\x16CreateSSOClientRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12!\n" + + "\fredirect_uri\x18\x02 \x01(\tR\vredirectUri\x12\x12\n" + + "\x04tags\x18\x03 \x03(\tR\x04tags\"F\n" + + "\x17CreateSSOClientResponse\x12+\n" + + "\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"2\n" + + "\x13GetSSOClientRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\"C\n" + + "\x14GetSSOClientResponse\x12+\n" + + "\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"\xce\x01\n" + + "\x16UpdateSSOClientRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12&\n" + + "\fredirect_uri\x18\x02 \x01(\tH\x00R\vredirectUri\x88\x01\x01\x12\x12\n" + + "\x04tags\x18\x03 \x03(\tR\x04tags\x12\x1d\n" + + "\aenabled\x18\x04 \x01(\bH\x01R\aenabled\x88\x01\x01\x12\x1f\n" + + "\vupdate_tags\x18\x05 \x01(\bR\n" + + "updateTagsB\x0f\n" + + "\r_redirect_uriB\n" + + "\n" + + "\b_enabled\"F\n" + + "\x17UpdateSSOClientResponse\x12+\n" + + "\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"5\n" + + "\x16DeleteSSOClientRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\"\x19\n" + + "\x17DeleteSSOClientResponse2\xbe\x03\n" + + "\x10SSOClientService\x12S\n" + + "\x0eListSSOClients\x12\x1f.mcias.v1.ListSSOClientsRequest\x1a .mcias.v1.ListSSOClientsResponse\x12V\n" + + "\x0fCreateSSOClient\x12 .mcias.v1.CreateSSOClientRequest\x1a!.mcias.v1.CreateSSOClientResponse\x12M\n" + + "\fGetSSOClient\x12\x1d.mcias.v1.GetSSOClientRequest\x1a\x1e.mcias.v1.GetSSOClientResponse\x12V\n" + + "\x0fUpdateSSOClient\x12 .mcias.v1.UpdateSSOClientRequest\x1a!.mcias.v1.UpdateSSOClientResponse\x12V\n" + + "\x0fDeleteSSOClient\x12 .mcias.v1.DeleteSSOClientRequest\x1a!.mcias.v1.DeleteSSOClientResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3" + +var ( + file_mcias_v1_sso_client_proto_rawDescOnce sync.Once + file_mcias_v1_sso_client_proto_rawDescData []byte +) + +func file_mcias_v1_sso_client_proto_rawDescGZIP() []byte { + file_mcias_v1_sso_client_proto_rawDescOnce.Do(func() { + file_mcias_v1_sso_client_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mcias_v1_sso_client_proto_rawDesc), len(file_mcias_v1_sso_client_proto_rawDesc))) + }) + return file_mcias_v1_sso_client_proto_rawDescData +} + +var file_mcias_v1_sso_client_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_mcias_v1_sso_client_proto_goTypes = []any{ + (*SSOClient)(nil), // 0: mcias.v1.SSOClient + (*ListSSOClientsRequest)(nil), // 1: mcias.v1.ListSSOClientsRequest + (*ListSSOClientsResponse)(nil), // 2: mcias.v1.ListSSOClientsResponse + (*CreateSSOClientRequest)(nil), // 3: mcias.v1.CreateSSOClientRequest + (*CreateSSOClientResponse)(nil), // 4: mcias.v1.CreateSSOClientResponse + (*GetSSOClientRequest)(nil), // 5: mcias.v1.GetSSOClientRequest + (*GetSSOClientResponse)(nil), // 6: mcias.v1.GetSSOClientResponse + (*UpdateSSOClientRequest)(nil), // 7: mcias.v1.UpdateSSOClientRequest + (*UpdateSSOClientResponse)(nil), // 8: mcias.v1.UpdateSSOClientResponse + (*DeleteSSOClientRequest)(nil), // 9: mcias.v1.DeleteSSOClientRequest + (*DeleteSSOClientResponse)(nil), // 10: mcias.v1.DeleteSSOClientResponse +} +var file_mcias_v1_sso_client_proto_depIdxs = []int32{ + 0, // 0: mcias.v1.ListSSOClientsResponse.clients:type_name -> mcias.v1.SSOClient + 0, // 1: mcias.v1.CreateSSOClientResponse.client:type_name -> mcias.v1.SSOClient + 0, // 2: mcias.v1.GetSSOClientResponse.client:type_name -> mcias.v1.SSOClient + 0, // 3: mcias.v1.UpdateSSOClientResponse.client:type_name -> mcias.v1.SSOClient + 1, // 4: mcias.v1.SSOClientService.ListSSOClients:input_type -> mcias.v1.ListSSOClientsRequest + 3, // 5: mcias.v1.SSOClientService.CreateSSOClient:input_type -> mcias.v1.CreateSSOClientRequest + 5, // 6: mcias.v1.SSOClientService.GetSSOClient:input_type -> mcias.v1.GetSSOClientRequest + 7, // 7: mcias.v1.SSOClientService.UpdateSSOClient:input_type -> mcias.v1.UpdateSSOClientRequest + 9, // 8: mcias.v1.SSOClientService.DeleteSSOClient:input_type -> mcias.v1.DeleteSSOClientRequest + 2, // 9: mcias.v1.SSOClientService.ListSSOClients:output_type -> mcias.v1.ListSSOClientsResponse + 4, // 10: mcias.v1.SSOClientService.CreateSSOClient:output_type -> mcias.v1.CreateSSOClientResponse + 6, // 11: mcias.v1.SSOClientService.GetSSOClient:output_type -> mcias.v1.GetSSOClientResponse + 8, // 12: mcias.v1.SSOClientService.UpdateSSOClient:output_type -> mcias.v1.UpdateSSOClientResponse + 10, // 13: mcias.v1.SSOClientService.DeleteSSOClient:output_type -> mcias.v1.DeleteSSOClientResponse + 9, // [9:14] is the sub-list for method output_type + 4, // [4:9] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_mcias_v1_sso_client_proto_init() } +func file_mcias_v1_sso_client_proto_init() { + if File_mcias_v1_sso_client_proto != nil { + return + } + file_mcias_v1_sso_client_proto_msgTypes[7].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_sso_client_proto_rawDesc), len(file_mcias_v1_sso_client_proto_rawDesc)), + NumEnums: 0, + NumMessages: 11, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_mcias_v1_sso_client_proto_goTypes, + DependencyIndexes: file_mcias_v1_sso_client_proto_depIdxs, + MessageInfos: file_mcias_v1_sso_client_proto_msgTypes, + }.Build() + File_mcias_v1_sso_client_proto = out.File + file_mcias_v1_sso_client_proto_goTypes = nil + file_mcias_v1_sso_client_proto_depIdxs = nil +} diff --git a/gen/mcias/v1/sso_client_grpc.pb.go b/gen/mcias/v1/sso_client_grpc.pb.go new file mode 100644 index 0000000..3096908 --- /dev/null +++ b/gen/mcias/v1/sso_client_grpc.pb.go @@ -0,0 +1,289 @@ +// SSOClientService: CRUD management of SSO client registrations. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v6.32.1 +// source: mcias/v1/sso_client.proto + +package mciasv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + SSOClientService_ListSSOClients_FullMethodName = "/mcias.v1.SSOClientService/ListSSOClients" + SSOClientService_CreateSSOClient_FullMethodName = "/mcias.v1.SSOClientService/CreateSSOClient" + SSOClientService_GetSSOClient_FullMethodName = "/mcias.v1.SSOClientService/GetSSOClient" + SSOClientService_UpdateSSOClient_FullMethodName = "/mcias.v1.SSOClientService/UpdateSSOClient" + SSOClientService_DeleteSSOClient_FullMethodName = "/mcias.v1.SSOClientService/DeleteSSOClient" +) + +// SSOClientServiceClient is the client API for SSOClientService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// SSOClientService manages SSO client registrations (admin only). +type SSOClientServiceClient interface { + // ListSSOClients returns all registered SSO clients. + ListSSOClients(ctx context.Context, in *ListSSOClientsRequest, opts ...grpc.CallOption) (*ListSSOClientsResponse, error) + // CreateSSOClient registers a new SSO client. + CreateSSOClient(ctx context.Context, in *CreateSSOClientRequest, opts ...grpc.CallOption) (*CreateSSOClientResponse, error) + // GetSSOClient returns a single SSO client by client_id. + GetSSOClient(ctx context.Context, in *GetSSOClientRequest, opts ...grpc.CallOption) (*GetSSOClientResponse, error) + // UpdateSSOClient applies a partial update to an SSO client. + UpdateSSOClient(ctx context.Context, in *UpdateSSOClientRequest, opts ...grpc.CallOption) (*UpdateSSOClientResponse, error) + // DeleteSSOClient removes an SSO client registration. + DeleteSSOClient(ctx context.Context, in *DeleteSSOClientRequest, opts ...grpc.CallOption) (*DeleteSSOClientResponse, error) +} + +type sSOClientServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSSOClientServiceClient(cc grpc.ClientConnInterface) SSOClientServiceClient { + return &sSOClientServiceClient{cc} +} + +func (c *sSOClientServiceClient) ListSSOClients(ctx context.Context, in *ListSSOClientsRequest, opts ...grpc.CallOption) (*ListSSOClientsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSSOClientsResponse) + err := c.cc.Invoke(ctx, SSOClientService_ListSSOClients_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSOClientServiceClient) CreateSSOClient(ctx context.Context, in *CreateSSOClientRequest, opts ...grpc.CallOption) (*CreateSSOClientResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateSSOClientResponse) + err := c.cc.Invoke(ctx, SSOClientService_CreateSSOClient_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSOClientServiceClient) GetSSOClient(ctx context.Context, in *GetSSOClientRequest, opts ...grpc.CallOption) (*GetSSOClientResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSSOClientResponse) + err := c.cc.Invoke(ctx, SSOClientService_GetSSOClient_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSOClientServiceClient) UpdateSSOClient(ctx context.Context, in *UpdateSSOClientRequest, opts ...grpc.CallOption) (*UpdateSSOClientResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateSSOClientResponse) + err := c.cc.Invoke(ctx, SSOClientService_UpdateSSOClient_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSOClientServiceClient) DeleteSSOClient(ctx context.Context, in *DeleteSSOClientRequest, opts ...grpc.CallOption) (*DeleteSSOClientResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteSSOClientResponse) + err := c.cc.Invoke(ctx, SSOClientService_DeleteSSOClient_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SSOClientServiceServer is the server API for SSOClientService service. +// All implementations must embed UnimplementedSSOClientServiceServer +// for forward compatibility. +// +// SSOClientService manages SSO client registrations (admin only). +type SSOClientServiceServer interface { + // ListSSOClients returns all registered SSO clients. + ListSSOClients(context.Context, *ListSSOClientsRequest) (*ListSSOClientsResponse, error) + // CreateSSOClient registers a new SSO client. + CreateSSOClient(context.Context, *CreateSSOClientRequest) (*CreateSSOClientResponse, error) + // GetSSOClient returns a single SSO client by client_id. + GetSSOClient(context.Context, *GetSSOClientRequest) (*GetSSOClientResponse, error) + // UpdateSSOClient applies a partial update to an SSO client. + UpdateSSOClient(context.Context, *UpdateSSOClientRequest) (*UpdateSSOClientResponse, error) + // DeleteSSOClient removes an SSO client registration. + DeleteSSOClient(context.Context, *DeleteSSOClientRequest) (*DeleteSSOClientResponse, error) + mustEmbedUnimplementedSSOClientServiceServer() +} + +// UnimplementedSSOClientServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedSSOClientServiceServer struct{} + +func (UnimplementedSSOClientServiceServer) ListSSOClients(context.Context, *ListSSOClientsRequest) (*ListSSOClientsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSSOClients not implemented") +} +func (UnimplementedSSOClientServiceServer) CreateSSOClient(context.Context, *CreateSSOClientRequest) (*CreateSSOClientResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSSOClient not implemented") +} +func (UnimplementedSSOClientServiceServer) GetSSOClient(context.Context, *GetSSOClientRequest) (*GetSSOClientResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSSOClient not implemented") +} +func (UnimplementedSSOClientServiceServer) UpdateSSOClient(context.Context, *UpdateSSOClientRequest) (*UpdateSSOClientResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateSSOClient not implemented") +} +func (UnimplementedSSOClientServiceServer) DeleteSSOClient(context.Context, *DeleteSSOClientRequest) (*DeleteSSOClientResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteSSOClient not implemented") +} +func (UnimplementedSSOClientServiceServer) mustEmbedUnimplementedSSOClientServiceServer() {} +func (UnimplementedSSOClientServiceServer) testEmbeddedByValue() {} + +// UnsafeSSOClientServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SSOClientServiceServer will +// result in compilation errors. +type UnsafeSSOClientServiceServer interface { + mustEmbedUnimplementedSSOClientServiceServer() +} + +func RegisterSSOClientServiceServer(s grpc.ServiceRegistrar, srv SSOClientServiceServer) { + // If the following call panics, it indicates UnimplementedSSOClientServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&SSOClientService_ServiceDesc, srv) +} + +func _SSOClientService_ListSSOClients_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSSOClientsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSOClientServiceServer).ListSSOClients(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSOClientService_ListSSOClients_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSOClientServiceServer).ListSSOClients(ctx, req.(*ListSSOClientsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSOClientService_CreateSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSSOClientRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSOClientServiceServer).CreateSSOClient(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSOClientService_CreateSSOClient_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSOClientServiceServer).CreateSSOClient(ctx, req.(*CreateSSOClientRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSOClientService_GetSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSSOClientRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSOClientServiceServer).GetSSOClient(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSOClientService_GetSSOClient_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSOClientServiceServer).GetSSOClient(ctx, req.(*GetSSOClientRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSOClientService_UpdateSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateSSOClientRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSOClientServiceServer).UpdateSSOClient(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSOClientService_UpdateSSOClient_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSOClientServiceServer).UpdateSSOClient(ctx, req.(*UpdateSSOClientRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSOClientService_DeleteSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteSSOClientRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSOClientServiceServer).DeleteSSOClient(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSOClientService_DeleteSSOClient_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSOClientServiceServer).DeleteSSOClient(ctx, req.(*DeleteSSOClientRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SSOClientService_ServiceDesc is the grpc.ServiceDesc for SSOClientService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SSOClientService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "mcias.v1.SSOClientService", + HandlerType: (*SSOClientServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListSSOClients", + Handler: _SSOClientService_ListSSOClients_Handler, + }, + { + MethodName: "CreateSSOClient", + Handler: _SSOClientService_CreateSSOClient_Handler, + }, + { + MethodName: "GetSSOClient", + Handler: _SSOClientService_GetSSOClient_Handler, + }, + { + MethodName: "UpdateSSOClient", + Handler: _SSOClientService_UpdateSSOClient_Handler, + }, + { + MethodName: "DeleteSSOClient", + Handler: _SSOClientService_DeleteSSOClient_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "mcias/v1/sso_client.proto", +} diff --git a/internal/config/config.go b/internal/config/config.go index 7820a68..3954aee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,24 +22,6 @@ type Config struct { //nolint:govet // fieldalignment: TOML section order is mor Tokens TokensConfig `toml:"tokens"` Argon2 Argon2Config `toml:"argon2"` WebAuthn WebAuthnConfig `toml:"webauthn"` - SSO SSOConfig `toml:"sso"` -} - -// SSOConfig holds registered SSO clients that may use the authorization code -// flow to authenticate users via MCIAS. Omitting the [sso] section or leaving -// clients empty disables SSO. -type SSOConfig struct { - Clients []SSOClient `toml:"clients"` -} - -// SSOClient is a registered relying-party application that may redirect users -// to MCIAS for login. The redirect_uri is validated as an exact match (no -// wildcards) to prevent open-redirect attacks. -type SSOClient struct { - ClientID string `toml:"client_id"` // unique identifier (e.g. "mcr") - RedirectURI string `toml:"redirect_uri"` // exact callback URL, https required - ServiceName string `toml:"service_name"` // passed to policy engine on login - Tags []string `toml:"tags"` // passed to policy engine on login } // WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn] @@ -264,48 +246,9 @@ func (c *Config) validate() error { } } - // SSO clients — if any are configured, each must have a unique client_id, - // a non-empty redirect_uri with the https:// scheme, and a non-empty - // service_name. - seen := make(map[string]bool, len(c.SSO.Clients)) - for i, cl := range c.SSO.Clients { - prefix := fmt.Sprintf("sso.clients[%d]", i) - if cl.ClientID == "" { - errs = append(errs, fmt.Errorf("%s: client_id is required", prefix)) - } else if seen[cl.ClientID] { - errs = append(errs, fmt.Errorf("%s: duplicate client_id %q", prefix, cl.ClientID)) - } else { - seen[cl.ClientID] = true - } - if cl.RedirectURI == "" { - errs = append(errs, fmt.Errorf("%s: redirect_uri is required", prefix)) - } else if !strings.HasPrefix(cl.RedirectURI, "https://") { - errs = append(errs, fmt.Errorf("%s: redirect_uri must use the https:// scheme (got %q)", prefix, cl.RedirectURI)) - } - if cl.ServiceName == "" { - errs = append(errs, fmt.Errorf("%s: service_name is required", prefix)) - } - } - return errors.Join(errs...) } -// SSOClient looks up a registered SSO client by client_id. -// Returns nil if no client with that ID is registered. -func (c *Config) SSOClient(clientID string) *SSOClient { - for i := range c.SSO.Clients { - if c.SSO.Clients[i].ClientID == clientID { - return &c.SSO.Clients[i] - } - } - return nil -} - -// SSOEnabled reports whether any SSO clients are registered. -func (c *Config) SSOEnabled() bool { - return len(c.SSO.Clients) > 0 -} - // DefaultExpiry returns the configured default token expiry duration. func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ae61448..611f471 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -244,153 +244,6 @@ func TestTrustedProxyValidation(t *testing.T) { } } -func TestSSOClientValidation(t *testing.T) { - tests := []struct { - name string - extra string - wantErr bool - }{ - { - name: "valid single client", - extra: ` -[[sso.clients]] -client_id = "mcr" -redirect_uri = "https://mcr.example.com/sso/callback" -service_name = "mcr" -tags = ["env:restricted"] -`, - wantErr: false, - }, - { - name: "valid multiple clients", - extra: ` -[[sso.clients]] -client_id = "mcr" -redirect_uri = "https://mcr.example.com/sso/callback" -service_name = "mcr" - -[[sso.clients]] -client_id = "mcat" -redirect_uri = "https://mcat.example.com/sso/callback" -service_name = "mcat" -`, - wantErr: false, - }, - { - name: "missing client_id", - extra: ` -[[sso.clients]] -redirect_uri = "https://mcr.example.com/sso/callback" -service_name = "mcr" -`, - wantErr: true, - }, - { - name: "missing redirect_uri", - extra: ` -[[sso.clients]] -client_id = "mcr" -service_name = "mcr" -`, - wantErr: true, - }, - { - name: "http redirect_uri rejected", - extra: ` -[[sso.clients]] -client_id = "mcr" -redirect_uri = "http://mcr.example.com/sso/callback" -service_name = "mcr" -`, - wantErr: true, - }, - { - name: "missing service_name", - extra: ` -[[sso.clients]] -client_id = "mcr" -redirect_uri = "https://mcr.example.com/sso/callback" -`, - wantErr: true, - }, - { - name: "duplicate client_id", - extra: ` -[[sso.clients]] -client_id = "mcr" -redirect_uri = "https://mcr.example.com/sso/callback" -service_name = "mcr" - -[[sso.clients]] -client_id = "mcr" -redirect_uri = "https://other.example.com/sso/callback" -service_name = "mcr2" -`, - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - path := writeTempConfig(t, validConfig()+tc.extra) - _, err := Load(path) - if tc.wantErr && err == nil { - t.Error("expected validation error, got nil") - } - if !tc.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - } -} - -func TestSSOClientLookup(t *testing.T) { - path := writeTempConfig(t, validConfig()+` -[[sso.clients]] -client_id = "mcr" -redirect_uri = "https://mcr.example.com/sso/callback" -service_name = "mcr" -tags = ["env:restricted"] -`) - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - cl := cfg.SSOClient("mcr") - if cl == nil { - t.Fatal("SSOClient(mcr) returned nil") - } - if cl.RedirectURI != "https://mcr.example.com/sso/callback" { - t.Errorf("RedirectURI = %q", cl.RedirectURI) - } - if cl.ServiceName != "mcr" { - t.Errorf("ServiceName = %q", cl.ServiceName) - } - if len(cl.Tags) != 1 || cl.Tags[0] != "env:restricted" { - t.Errorf("Tags = %v", cl.Tags) - } - - if cfg.SSOClient("nonexistent") != nil { - t.Error("SSOClient(nonexistent) should return nil") - } - - if !cfg.SSOEnabled() { - t.Error("SSOEnabled() should return true") - } -} - -func TestSSODisabledByDefault(t *testing.T) { - path := writeTempConfig(t, validConfig()) - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.SSOEnabled() { - t.Error("SSOEnabled() should return false with no clients") - } -} - func TestDurationParsing(t *testing.T) { var d duration if err := d.UnmarshalText([]byte("1h30m")); err != nil { diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 4d3891d..189d774 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -22,7 +22,7 @@ var migrationsFS embed.FS // LatestSchemaVersion is the highest migration version defined in the // migrations/ directory. Update this constant whenever a new migration file // is added. -const LatestSchemaVersion = 9 +const LatestSchemaVersion = 10 // newMigrate constructs a migrate.Migrate instance backed by the embedded SQL // files. It opens a dedicated *sql.DB using the same DSN as the main diff --git a/internal/db/migrations/000010_sso_clients.up.sql b/internal/db/migrations/000010_sso_clients.up.sql new file mode 100644 index 0000000..7568ce1 --- /dev/null +++ b/internal/db/migrations/000010_sso_clients.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE sso_clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL UNIQUE, + redirect_uri TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)), + created_by INTEGER REFERENCES accounts(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); diff --git a/internal/db/sso_clients.go b/internal/db/sso_clients.go new file mode 100644 index 0000000..9af46db --- /dev/null +++ b/internal/db/sso_clients.go @@ -0,0 +1,206 @@ +package db + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "git.wntrmute.dev/mc/mcias/internal/model" +) + +const ssoClientCols = `id, client_id, redirect_uri, tags_json, enabled, created_by, created_at, updated_at` + +// CreateSSOClient inserts a new SSO client. The client_id must be unique +// and the redirect_uri must start with "https://". +func (db *DB) CreateSSOClient(clientID, redirectURI string, tags []string, createdBy *int64) (*model.SSOClient, error) { + if clientID == "" { + return nil, fmt.Errorf("db: client_id is required") + } + if !strings.HasPrefix(redirectURI, "https://") { + return nil, fmt.Errorf("db: redirect_uri must start with https://") + } + if tags == nil { + tags = []string{} + } + + tagsJSON, err := json.Marshal(tags) + if err != nil { + return nil, fmt.Errorf("db: marshal tags: %w", err) + } + + n := now() + result, err := db.sql.Exec(` + INSERT INTO sso_clients (client_id, redirect_uri, tags_json, enabled, created_by, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?, ?) + `, clientID, redirectURI, string(tagsJSON), createdBy, n, n) + if err != nil { + return nil, fmt.Errorf("db: create SSO client: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("db: create SSO client last insert id: %w", err) + } + + createdAt, err := parseTime(n) + if err != nil { + return nil, err + } + + return &model.SSOClient{ + ID: id, + ClientID: clientID, + RedirectURI: redirectURI, + Tags: tags, + Enabled: true, + CreatedBy: createdBy, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }, nil +} + +// GetSSOClient retrieves an SSO client by client_id. +// Returns ErrNotFound if no such client exists. +func (db *DB) GetSSOClient(clientID string) (*model.SSOClient, error) { + return scanSSOClient(db.sql.QueryRow(` + SELECT `+ssoClientCols+` + FROM sso_clients WHERE client_id = ? + `, clientID)) +} + +// ListSSOClients returns all SSO clients ordered by client_id. +func (db *DB) ListSSOClients() ([]*model.SSOClient, error) { + rows, err := db.sql.Query(` + SELECT ` + ssoClientCols + ` + FROM sso_clients ORDER BY client_id ASC + `) + if err != nil { + return nil, fmt.Errorf("db: list SSO clients: %w", err) + } + defer func() { _ = rows.Close() }() + + var clients []*model.SSOClient + for rows.Next() { + c, err := scanSSOClientRow(rows) + if err != nil { + return nil, err + } + clients = append(clients, c) + } + return clients, rows.Err() +} + +// UpdateSSOClient updates the mutable fields of an SSO client. +// Only non-nil fields are changed. +func (db *DB) UpdateSSOClient(clientID string, redirectURI *string, tags *[]string, enabled *bool) error { + n := now() + setClauses := "updated_at = ?" + args := []interface{}{n} + + if redirectURI != nil { + if !strings.HasPrefix(*redirectURI, "https://") { + return fmt.Errorf("db: redirect_uri must start with https://") + } + setClauses += ", redirect_uri = ?" + args = append(args, *redirectURI) + } + if tags != nil { + tagsJSON, err := json.Marshal(*tags) + if err != nil { + return fmt.Errorf("db: marshal tags: %w", err) + } + setClauses += ", tags_json = ?" + args = append(args, string(tagsJSON)) + } + if enabled != nil { + enabledInt := 0 + if *enabled { + enabledInt = 1 + } + setClauses += ", enabled = ?" + args = append(args, enabledInt) + } + args = append(args, clientID) + + res, err := db.sql.Exec(`UPDATE sso_clients SET `+setClauses+` WHERE client_id = ?`, args...) + if err != nil { + return fmt.Errorf("db: update SSO client %s: %w", clientID, err) + } + n2, _ := res.RowsAffected() + if n2 == 0 { + return ErrNotFound + } + return nil +} + +// DeleteSSOClient removes an SSO client by client_id. +func (db *DB) DeleteSSOClient(clientID string) error { + res, err := db.sql.Exec(`DELETE FROM sso_clients WHERE client_id = ?`, clientID) + if err != nil { + return fmt.Errorf("db: delete SSO client %s: %w", clientID, err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// scanSSOClient scans a single SSO client from a *sql.Row. +func scanSSOClient(row *sql.Row) (*model.SSOClient, error) { + var c model.SSOClient + var enabledInt int + var tagsJSON, createdAtStr, updatedAtStr string + var createdBy *int64 + + err := row.Scan(&c.ID, &c.ClientID, &c.RedirectURI, &tagsJSON, + &enabledInt, &createdBy, &createdAtStr, &updatedAtStr) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("db: scan SSO client: %w", err) + } + + return finishSSOClientScan(&c, enabledInt, createdBy, tagsJSON, createdAtStr, updatedAtStr) +} + +// scanSSOClientRow scans a single SSO client from *sql.Rows. +func scanSSOClientRow(rows *sql.Rows) (*model.SSOClient, error) { + var c model.SSOClient + var enabledInt int + var tagsJSON, createdAtStr, updatedAtStr string + var createdBy *int64 + + err := rows.Scan(&c.ID, &c.ClientID, &c.RedirectURI, &tagsJSON, + &enabledInt, &createdBy, &createdAtStr, &updatedAtStr) + if err != nil { + return nil, fmt.Errorf("db: scan SSO client row: %w", err) + } + + return finishSSOClientScan(&c, enabledInt, createdBy, tagsJSON, createdAtStr, updatedAtStr) +} + +func finishSSOClientScan(c *model.SSOClient, enabledInt int, createdBy *int64, tagsJSON, createdAtStr, updatedAtStr string) (*model.SSOClient, error) { + c.Enabled = enabledInt == 1 + c.CreatedBy = createdBy + + var err error + if c.CreatedAt, err = parseTime(createdAtStr); err != nil { + return nil, err + } + if c.UpdatedAt, err = parseTime(updatedAtStr); err != nil { + return nil, err + } + + if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil { + return nil, fmt.Errorf("db: unmarshal SSO client tags: %w", err) + } + if c.Tags == nil { + c.Tags = []string{} + } + + return c, nil +} diff --git a/internal/db/sso_clients_test.go b/internal/db/sso_clients_test.go new file mode 100644 index 0000000..d346d5f --- /dev/null +++ b/internal/db/sso_clients_test.go @@ -0,0 +1,192 @@ +package db + +import ( + "errors" + "testing" +) + +func TestCreateAndGetSSOClient(t *testing.T) { + db := openTestDB(t) + + c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/sso/callback", []string{"env:prod"}, nil) + if err != nil { + t.Fatalf("CreateSSOClient: %v", err) + } + if c.ID == 0 { + t.Error("expected non-zero ID") + } + if c.ClientID != "mcr" { + t.Errorf("client_id = %q, want %q", c.ClientID, "mcr") + } + if !c.Enabled { + t.Error("new client should be enabled by default") + } + if len(c.Tags) != 1 || c.Tags[0] != "env:prod" { + t.Errorf("tags = %v, want [env:prod]", c.Tags) + } + + got, err := db.GetSSOClient("mcr") + if err != nil { + t.Fatalf("GetSSOClient: %v", err) + } + if got.RedirectURI != "https://mcr.example.com/sso/callback" { + t.Errorf("redirect_uri = %q", got.RedirectURI) + } + if len(got.Tags) != 1 || got.Tags[0] != "env:prod" { + t.Errorf("tags = %v after round-trip", got.Tags) + } +} + +func TestCreateSSOClient_DuplicateClientID(t *testing.T) { + db := openTestDB(t) + + _, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil) + if err != nil { + t.Fatalf("first create: %v", err) + } + + _, err = db.CreateSSOClient("mcr", "https://other.example.com/cb", nil, nil) + if err == nil { + t.Error("expected error for duplicate client_id") + } +} + +func TestCreateSSOClient_Validation(t *testing.T) { + db := openTestDB(t) + + _, err := db.CreateSSOClient("", "https://example.com/cb", nil, nil) + if err == nil { + t.Error("expected error for empty client_id") + } + + _, err = db.CreateSSOClient("mcr", "http://example.com/cb", nil, nil) + if err == nil { + t.Error("expected error for non-https redirect_uri") + } +} + +func TestGetSSOClient_NotFound(t *testing.T) { + db := openTestDB(t) + + _, err := db.GetSSOClient("nonexistent") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestListSSOClients(t *testing.T) { + db := openTestDB(t) + + clients, err := db.ListSSOClients() + if err != nil { + t.Fatalf("ListSSOClients (empty): %v", err) + } + if len(clients) != 0 { + t.Errorf("expected 0 clients, got %d", len(clients)) + } + + _, _ = db.CreateSSOClient("mcat", "https://mcat.example.com/cb", nil, nil) + _, _ = db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil) + + clients, err = db.ListSSOClients() + if err != nil { + t.Fatalf("ListSSOClients: %v", err) + } + if len(clients) != 2 { + t.Fatalf("expected 2 clients, got %d", len(clients)) + } + // Ordered by client_id ASC. + if clients[0].ClientID != "mcat" { + t.Errorf("first client = %q, want %q", clients[0].ClientID, "mcat") + } +} + +func TestUpdateSSOClient(t *testing.T) { + db := openTestDB(t) + + _, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", []string{"a"}, nil) + if err != nil { + t.Fatalf("create: %v", err) + } + + newURI := "https://mcr.example.com/sso/callback" + newTags := []string{"b", "c"} + disabled := false + if err := db.UpdateSSOClient("mcr", &newURI, &newTags, &disabled); err != nil { + t.Fatalf("UpdateSSOClient: %v", err) + } + + got, err := db.GetSSOClient("mcr") + if err != nil { + t.Fatalf("get after update: %v", err) + } + if got.RedirectURI != newURI { + t.Errorf("redirect_uri = %q, want %q", got.RedirectURI, newURI) + } + if len(got.Tags) != 2 || got.Tags[0] != "b" { + t.Errorf("tags = %v, want [b c]", got.Tags) + } + if got.Enabled { + t.Error("expected enabled=false after update") + } +} + +func TestUpdateSSOClient_NotFound(t *testing.T) { + db := openTestDB(t) + + uri := "https://x.example.com/cb" + err := db.UpdateSSOClient("nonexistent", &uri, nil, nil) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestDeleteSSOClient(t *testing.T) { + db := openTestDB(t) + + _, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil) + if err != nil { + t.Fatalf("create: %v", err) + } + + if err := db.DeleteSSOClient("mcr"); err != nil { + t.Fatalf("DeleteSSOClient: %v", err) + } + + _, err = db.GetSSOClient("mcr") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestDeleteSSOClient_NotFound(t *testing.T) { + db := openTestDB(t) + + err := db.DeleteSSOClient("nonexistent") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestCreateSSOClient_NilTags(t *testing.T) { + db := openTestDB(t) + + c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil) + if err != nil { + t.Fatalf("create: %v", err) + } + if c.Tags == nil { + t.Error("Tags should be empty slice, not nil") + } + if len(c.Tags) != 0 { + t.Errorf("expected 0 tags, got %d", len(c.Tags)) + } + + got, err := db.GetSSOClient("mcr") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Tags == nil || len(got.Tags) != 0 { + t.Errorf("Tags round-trip: got %v", got.Tags) + } +} diff --git a/internal/grpcserver/grpcserver.go b/internal/grpcserver/grpcserver.go index 7d7302e..f02ad3a 100644 --- a/internal/grpcserver/grpcserver.go +++ b/internal/grpcserver/grpcserver.go @@ -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 } diff --git a/internal/grpcserver/ssoclientservice.go b/internal/grpcserver/ssoclientservice.go new file mode 100644 index 0000000..6246e17 --- /dev/null +++ b/internal/grpcserver/ssoclientservice.go @@ -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 +} diff --git a/internal/model/model.go b/internal/model/model.go index 9adcc2f..a58e3a0 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -221,8 +221,26 @@ const ( EventSSOAuthorize = "sso_authorize" EventSSOLoginOK = "sso_login_ok" + + EventSSOClientCreated = "sso_client_created" + EventSSOClientUpdated = "sso_client_updated" + EventSSOClientDeleted = "sso_client_deleted" ) +// SSOClient represents a registered relying-party application that may use +// the MCIAS SSO authorization code flow. The ClientID serves as both the +// unique identifier and the service_name for policy evaluation. +type SSOClient struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy *int64 `json:"-"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Tags []string `json:"tags"` + ID int64 `json:"-"` + Enabled bool `json:"enabled"` +} + // ServiceAccountDelegate records that a specific account has been granted // permission to issue tokens for a given system account. Only admins can // add or remove delegates; delegates can issue/rotate tokens for that specific diff --git a/internal/policy/policy.go b/internal/policy/policy.go index fc5c1f3..d4a6ba6 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -51,6 +51,8 @@ const ( ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service ActionRemoveWebAuthn Action = "webauthn:remove" // admin + + ActionManageSSOClients Action = "sso_clients:manage" // admin ) // ResourceType identifies what kind of object a request targets. @@ -62,8 +64,9 @@ const ( ResourcePGCreds ResourceType = "pgcreds" ResourceAuditLog ResourceType = "audit_log" ResourceTOTP ResourceType = "totp" - ResourcePolicy ResourceType = "policy" - ResourceWebAuthn ResourceType = "webauthn" + ResourcePolicy ResourceType = "policy" + ResourceWebAuthn ResourceType = "webauthn" + ResourceSSOClient ResourceType = "sso_client" ) // Effect is the outcome of policy evaluation. diff --git a/internal/server/handlers_sso.go b/internal/server/handlers_sso.go index 94f94c5..619c8f3 100644 --- a/internal/server/handlers_sso.go +++ b/internal/server/handlers_sso.go @@ -54,13 +54,17 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request) return } - // Look up the registered SSO client for policy context. - client := s.cfg.SSOClient(req.ClientID) - if client == nil { + // Look up the registered SSO client from the database for policy context. + client, clientErr := s.db.GetSSOClient(req.ClientID) + if clientErr != nil { // Should not happen if the authorize endpoint validated, but defend in depth. middleware.WriteError(w, http.StatusUnauthorized, "unknown client", "invalid_code") return } + if !client.Enabled { + middleware.WriteError(w, http.StatusForbidden, "SSO client is disabled", "client_disabled") + return + } // Load account. acct, err := s.db.GetAccountByID(ac.AccountID) @@ -82,7 +86,7 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request) return } - // Policy evaluation with the SSO client's service_name and tags. + // Policy evaluation: client_id serves as both identifier and service_name. { input := policy.PolicyInput{ Subject: acct.UUID, @@ -90,13 +94,13 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request) Roles: roles, Action: policy.ActionLogin, Resource: policy.Resource{ - ServiceName: client.ServiceName, + ServiceName: client.ClientID, Tags: client.Tags, }, } if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny { s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, - audit.JSON("reason", "policy_deny", "service_name", client.ServiceName, "via", "sso")) + audit.JSON("reason", "policy_deny", "service_name", client.ClientID, "via", "sso")) middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied") return } diff --git a/internal/server/handlers_sso_clients.go b/internal/server/handlers_sso_clients.go new file mode 100644 index 0000000..094096d --- /dev/null +++ b/internal/server/handlers_sso_clients.go @@ -0,0 +1,175 @@ +package server + +import ( + "errors" + "fmt" + "net/http" + "time" + + "git.wntrmute.dev/mc/mcias/internal/db" + "git.wntrmute.dev/mc/mcias/internal/middleware" + "git.wntrmute.dev/mc/mcias/internal/model" +) + +type ssoClientResponse struct { + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Tags []string `json:"tags"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func ssoClientToResponse(c *model.SSOClient) ssoClientResponse { + return ssoClientResponse{ + ClientID: c.ClientID, + RedirectURI: c.RedirectURI, + Tags: c.Tags, + Enabled: c.Enabled, + CreatedAt: c.CreatedAt.Format(time.RFC3339), + UpdatedAt: c.UpdatedAt.Format(time.RFC3339), + } +} + +func (s *Server) handleListSSOClients(w http.ResponseWriter, r *http.Request) { + clients, err := s.db.ListSSOClients() + if err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + resp := make([]ssoClientResponse, 0, len(clients)) + for _, c := range clients { + resp = append(resp, ssoClientToResponse(c)) + } + writeJSON(w, http.StatusOK, resp) +} + +type createSSOClientRequest struct { + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Tags []string `json:"tags"` +} + +func (s *Server) handleCreateSSOClient(w http.ResponseWriter, r *http.Request) { + var req createSSOClientRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.ClientID == "" { + middleware.WriteError(w, http.StatusBadRequest, "client_id is required", "bad_request") + return + } + if req.RedirectURI == "" { + middleware.WriteError(w, http.StatusBadRequest, "redirect_uri is required", "bad_request") + return + } + + claims := middleware.ClaimsFromContext(r.Context()) + var createdBy *int64 + if claims != nil { + if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil { + createdBy = &actor.ID + } + } + + c, err := s.db.CreateSSOClient(req.ClientID, req.RedirectURI, req.Tags, createdBy) + if err != nil { + s.logger.Error("create SSO client", "error", err) + middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request") + return + } + + s.writeAudit(r, model.EventSSOClientCreated, createdBy, nil, + fmt.Sprintf(`{"client_id":%q}`, c.ClientID)) + + writeJSON(w, http.StatusCreated, ssoClientToResponse(c)) +} + +func (s *Server) handleGetSSOClient(w http.ResponseWriter, r *http.Request) { + clientID := r.PathValue("clientId") + + c, err := s.db.GetSSOClient(clientID) + if errors.Is(err, db.ErrNotFound) { + middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found") + return + } + if err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + writeJSON(w, http.StatusOK, ssoClientToResponse(c)) +} + +type updateSSOClientRequest struct { + RedirectURI *string `json:"redirect_uri,omitempty"` + Tags *[]string `json:"tags,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +func (s *Server) handleUpdateSSOClient(w http.ResponseWriter, r *http.Request) { + clientID := r.PathValue("clientId") + + var req updateSSOClientRequest + if !decodeJSON(w, r, &req) { + return + } + + err := s.db.UpdateSSOClient(clientID, req.RedirectURI, req.Tags, req.Enabled) + if errors.Is(err, db.ErrNotFound) { + middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found") + return + } + if err != nil { + s.logger.Error("update SSO client", "error", err) + middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request") + return + } + + claims := middleware.ClaimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &actor.ID + } + } + + s.writeAudit(r, model.EventSSOClientUpdated, actorID, nil, + fmt.Sprintf(`{"client_id":%q}`, clientID)) + + c, err := s.db.GetSSOClient(clientID) + if err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + writeJSON(w, http.StatusOK, ssoClientToResponse(c)) +} + +func (s *Server) handleDeleteSSOClient(w http.ResponseWriter, r *http.Request) { + clientID := r.PathValue("clientId") + + err := s.db.DeleteSSOClient(clientID) + if errors.Is(err, db.ErrNotFound) { + middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found") + return + } + if err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + claims := middleware.ClaimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &actor.ID + } + } + + s.writeAudit(r, model.EventSSOClientDeleted, actorID, nil, + fmt.Sprintf(`{"client_id":%q}`, clientID)) + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/server.go b/internal/server/server.go index 8604ef0..1f41436 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -373,6 +373,18 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /v1/policy/rules/{id}", requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule))) + // SSO client management (admin-only). + mux.Handle("GET /v1/sso/clients", + requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleListSSOClients))) + mux.Handle("POST /v1/sso/clients", + requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleCreateSSOClient))) + mux.Handle("GET /v1/sso/clients/{clientId}", + requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleGetSSOClient))) + mux.Handle("PATCH /v1/sso/clients/{clientId}", + requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleUpdateSSOClient))) + mux.Handle("DELETE /v1/sso/clients/{clientId}", + requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleDeleteSSOClient))) + // UI routes (HTMX-based management frontend). uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger) if err != nil { diff --git a/internal/ui/handlers_sso.go b/internal/ui/handlers_sso.go index 2d1a9b1..5a259c7 100644 --- a/internal/ui/handlers_sso.go +++ b/internal/ui/handlers_sso.go @@ -26,14 +26,20 @@ func (u *UIServer) handleSSOAuthorize(w http.ResponseWriter, r *http.Request) { return } - // Security: validate client_id against registered SSO clients. - client := u.cfg.SSOClient(clientID) - if client == nil { + // Security: validate client_id against registered SSO clients in the database. + client, err := u.db.GetSSOClient(clientID) + if err != nil { u.logger.Warn("sso: unknown client_id", "client_id", clientID) http.Error(w, "unknown client_id", http.StatusBadRequest) return } + if !client.Enabled { + u.logger.Warn("sso: disabled client", "client_id", clientID) + http.Error(w, "SSO client is disabled", http.StatusForbidden) + return + } + // Security: redirect_uri must exactly match the registered URI to prevent // open-redirect attacks. if redirectURI != client.RedirectURI { diff --git a/internal/ui/handlers_sso_clients.go b/internal/ui/handlers_sso_clients.go new file mode 100644 index 0000000..5284242 --- /dev/null +++ b/internal/ui/handlers_sso_clients.go @@ -0,0 +1,129 @@ +package ui + +import ( + "fmt" + "net/http" + "strings" + + "git.wntrmute.dev/mc/mcias/internal/audit" + "git.wntrmute.dev/mc/mcias/internal/model" +) + +func (u *UIServer) handleSSOClientsPage(w http.ResponseWriter, r *http.Request) { + csrfToken, err := u.setCSRFCookies(w) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + clients, err := u.db.ListSSOClients() + if err != nil { + u.renderError(w, r, http.StatusInternalServerError, "failed to load SSO clients") + return + } + + u.render(w, "sso_clients", SSOClientsData{ + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, + Clients: clients, + }) +} + +func (u *UIServer) handleCreateSSOClientUI(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) + if err := r.ParseForm(); err != nil { + u.renderError(w, r, http.StatusBadRequest, "invalid form submission") + return + } + + clientID := strings.TrimSpace(r.FormValue("client_id")) + redirectURI := strings.TrimSpace(r.FormValue("redirect_uri")) + tagsStr := strings.TrimSpace(r.FormValue("tags")) + + if clientID == "" || redirectURI == "" { + u.renderError(w, r, http.StatusBadRequest, "client_id and redirect_uri are required") + return + } + + var tags []string + if tagsStr != "" { + for _, t := range strings.Split(tagsStr, ",") { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + } + + claims := claimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if acct, err := u.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &acct.ID + } + } + + c, err := u.db.CreateSSOClient(clientID, redirectURI, tags, actorID) + if err != nil { + u.renderError(w, r, http.StatusBadRequest, err.Error()) + return + } + + u.writeAudit(r, model.EventSSOClientCreated, actorID, nil, + audit.JSON("client_id", c.ClientID)) + + u.render(w, "sso_client_row", c) +} + +func (u *UIServer) handleToggleSSOClient(w http.ResponseWriter, r *http.Request) { + clientID := r.PathValue("clientId") + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) + if err := r.ParseForm(); err != nil { + u.renderError(w, r, http.StatusBadRequest, "invalid form") + return + } + + enabled := r.FormValue("enabled") == "true" + if err := u.db.UpdateSSOClient(clientID, nil, nil, &enabled); err != nil { + u.renderError(w, r, http.StatusInternalServerError, "failed to update SSO client") + return + } + + claims := claimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if acct, err := u.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &acct.ID + } + } + u.writeAudit(r, model.EventSSOClientUpdated, actorID, nil, + fmt.Sprintf(`{"client_id":%q,"enabled":%v}`, clientID, enabled)) + + c, err := u.db.GetSSOClient(clientID) + if err != nil { + u.renderError(w, r, http.StatusInternalServerError, "failed to reload SSO client") + return + } + u.render(w, "sso_client_row", c) +} + +func (u *UIServer) handleDeleteSSOClientUI(w http.ResponseWriter, r *http.Request) { + clientID := r.PathValue("clientId") + + if err := u.db.DeleteSSOClient(clientID); err != nil { + u.renderError(w, r, http.StatusInternalServerError, "failed to delete SSO client") + return + } + + claims := claimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if acct, err := u.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &acct.ID + } + } + u.writeAudit(r, model.EventSSOClientDeleted, actorID, nil, + audit.JSON("client_id", clientID)) + + // Return empty response so HTMX removes the row. + w.WriteHeader(http.StatusOK) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2afb558..e6eb2b8 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -501,6 +501,10 @@ func (u *UIServer) Register(mux *http.ServeMux) { uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule)) uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags)) uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword)) + uiMux.Handle("GET /sso-clients", adminGet(u.handleSSOClientsPage)) + uiMux.Handle("POST /sso-clients", admin(u.handleCreateSSOClientUI)) + uiMux.Handle("PATCH /sso-clients/{clientId}/toggle", admin(u.handleToggleSSOClient)) + uiMux.Handle("DELETE /sso-clients/{clientId}", admin(u.handleDeleteSSOClientUI)) // Service accounts page — accessible to any authenticated user; shows only // the service accounts for which the current user is a token-issue delegate. @@ -923,6 +927,12 @@ type PolicyRuleView struct { IsPending bool // true if not_before is in the future } +// SSOClientsData is the view model for the SSO clients admin page. +type SSOClientsData struct { + PageData + Clients []*model.SSOClient +} + // PoliciesData is the view model for the policies list page. type PoliciesData struct { PageData diff --git a/proto/generate.go b/proto/generate.go index 3715314..947fa51 100644 --- a/proto/generate.go +++ b/proto/generate.go @@ -6,5 +6,5 @@ // // Prerequisites: protoc, protoc-gen-go, protoc-gen-go-grpc must be in PATH. // -//go:generate protoc --proto_path=../proto --go_out=../gen --go_opt=paths=source_relative --go-grpc_out=../gen --go-grpc_opt=paths=source_relative mcias/v1/common.proto mcias/v1/admin.proto mcias/v1/auth.proto mcias/v1/token.proto mcias/v1/account.proto mcias/v1/policy.proto +//go:generate protoc --proto_path=../proto --go_out=../gen --go_opt=paths=source_relative --go-grpc_out=../gen --go-grpc_opt=paths=source_relative mcias/v1/common.proto mcias/v1/admin.proto mcias/v1/auth.proto mcias/v1/token.proto mcias/v1/account.proto mcias/v1/policy.proto mcias/v1/sso_client.proto package proto diff --git a/proto/mcias/v1/sso_client.proto b/proto/mcias/v1/sso_client.proto new file mode 100644 index 0000000..d75f7ff --- /dev/null +++ b/proto/mcias/v1/sso_client.proto @@ -0,0 +1,86 @@ +// SSOClientService: CRUD management of SSO client registrations. +syntax = "proto3"; + +package mcias.v1; + +option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1"; + +// SSOClient is the wire representation of an SSO client registration. +message SSOClient { + string client_id = 1; + string redirect_uri = 2; + repeated string tags = 3; + bool enabled = 4; + string created_at = 5; // RFC3339 + string updated_at = 6; // RFC3339 +} + +// --- List --- + +message ListSSOClientsRequest {} + +message ListSSOClientsResponse { + repeated SSOClient clients = 1; +} + +// --- Create --- + +message CreateSSOClientRequest { + string client_id = 1; + string redirect_uri = 2; + repeated string tags = 3; +} + +message CreateSSOClientResponse { + SSOClient client = 1; +} + +// --- Get --- + +message GetSSOClientRequest { + string client_id = 1; +} + +message GetSSOClientResponse { + SSOClient client = 1; +} + +// --- Update --- + +message UpdateSSOClientRequest { + string client_id = 1; + optional string redirect_uri = 2; + repeated string tags = 3; + optional bool enabled = 4; + bool update_tags = 5; // when true, tags field is applied (allows clearing) +} + +message UpdateSSOClientResponse { + SSOClient client = 1; +} + +// --- Delete --- + +message DeleteSSOClientRequest { + string client_id = 1; +} + +message DeleteSSOClientResponse {} + +// SSOClientService manages SSO client registrations (admin only). +service SSOClientService { + // ListSSOClients returns all registered SSO clients. + rpc ListSSOClients(ListSSOClientsRequest) returns (ListSSOClientsResponse); + + // CreateSSOClient registers a new SSO client. + rpc CreateSSOClient(CreateSSOClientRequest) returns (CreateSSOClientResponse); + + // GetSSOClient returns a single SSO client by client_id. + rpc GetSSOClient(GetSSOClientRequest) returns (GetSSOClientResponse); + + // UpdateSSOClient applies a partial update to an SSO client. + rpc UpdateSSOClient(UpdateSSOClientRequest) returns (UpdateSSOClientResponse); + + // DeleteSSOClient removes an SSO client registration. + rpc DeleteSSOClient(DeleteSSOClientRequest) returns (DeleteSSOClientResponse); +} diff --git a/web/templates/base.html b/web/templates/base.html index d2b3926..9035698 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -15,6 +15,7 @@ {{if .IsAdmin}}
  • Accounts
  • Audit
  • Policies
  • +
  • SSO Clients
  • PG Creds
  • {{else}}
  • Service Accounts
  • {{end}} {{if .ActorName}}
  • {{.ActorName}}
  • {{end}}
  • diff --git a/web/templates/fragments/sso_client_row.html b/web/templates/fragments/sso_client_row.html new file mode 100644 index 0000000..7d999d8 --- /dev/null +++ b/web/templates/fragments/sso_client_row.html @@ -0,0 +1,31 @@ +{{define "sso_client_row"}} + + {{.ClientID}} + {{.RedirectURI}} + {{range .Tags}}{{.}} {{end}} + + {{if .Enabled}}enabled + {{else}}disabled{{end}} + + + {{if .Enabled}} + + {{else}} + + {{end}} + + + +{{end}} diff --git a/web/templates/sso_clients.html b/web/templates/sso_clients.html new file mode 100644 index 0000000..27b8c48 --- /dev/null +++ b/web/templates/sso_clients.html @@ -0,0 +1,53 @@ +{{define "title"}} - SSO Clients{{end}} +{{define "content"}} + + + + +
    + + + + + + + + + + + + {{range .Clients}} + {{template "sso_client_row" .}} + {{end}} + +
    Client IDRedirect URITagsStatusActions
    + {{if not .Clients}}

    No SSO clients registered.

    {{end}} +
    +{{end}}