Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15e7eb5bd1 | |||
| e082671f53 | |||
| ef28805042 | |||
| 33e0f9b8bd | |||
| 44a1b9ad3a | |||
| df7773229c | |||
| 4430ce38a4 | |||
| 4ed2cecec5 |
@@ -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,
|
||||
|
||||
703
gen/mcias/v1/sso_client.pb.go
Normal file
703
gen/mcias/v1/sso_client.pb.go
Normal file
@@ -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
|
||||
}
|
||||
289
gen/mcias/v1/sso_client_grpc.pb.go
Normal file
289
gen/mcias/v1/sso_client_grpc.pb.go
Normal file
@@ -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",
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
10
internal/db/migrations/000010_sso_clients.up.sql
Normal file
10
internal/db/migrations/000010_sso_clients.up.sql
Normal file
@@ -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'))
|
||||
);
|
||||
206
internal/db/sso_clients.go
Normal file
206
internal/db/sso_clients.go
Normal file
@@ -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
|
||||
}
|
||||
192
internal/db/sso_clients_test.go
Normal file
192
internal/db/sso_clients_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -64,6 +66,7 @@ const (
|
||||
ResourceTOTP ResourceType = "totp"
|
||||
ResourcePolicy ResourceType = "policy"
|
||||
ResourceWebAuthn ResourceType = "webauthn"
|
||||
ResourceSSOClient ResourceType = "sso_client"
|
||||
)
|
||||
|
||||
// Effect is the outcome of policy evaluation.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
175
internal/server/handlers_sso_clients.go
Normal file
175
internal/server/handlers_sso_clients.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -211,6 +211,14 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
|
||||
// SSO redirect flow: issue authorization code and redirect to service.
|
||||
if ssoNonce != "" {
|
||||
if callbackURL, ok := u.buildSSOCallback(r, ssoNonce, acct.ID); ok {
|
||||
// Security: htmx follows 302 redirects via fetch, which fails
|
||||
// cross-origin (no CORS on the service callback). Use HX-Redirect
|
||||
// so htmx performs a full page navigation instead.
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", callbackURL)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, callbackURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
131
internal/ui/handlers_sso_clients.go
Normal file
131
internal/ui/handlers_sso_clients.go
Normal file
@@ -0,0 +1,131 @@
|
||||
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 {
|
||||
u.logger.Error("sso-clients: set CSRF cookies", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
clients, err := u.db.ListSSOClients()
|
||||
if err != nil {
|
||||
u.logger.Error("sso-clients: list clients", "error", err)
|
||||
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)
|
||||
}
|
||||
@@ -267,6 +267,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
||||
"templates/fragments/webauthn_enroll.html",
|
||||
"templates/fragments/totp_section.html",
|
||||
"templates/fragments/totp_enroll_qr.html",
|
||||
"templates/fragments/sso_client_row.html",
|
||||
}
|
||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||
if err != nil {
|
||||
@@ -287,6 +288,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
||||
"profile": "templates/profile.html",
|
||||
"unseal": "templates/unseal.html",
|
||||
"service_accounts": "templates/service_accounts.html",
|
||||
"sso_clients": "templates/sso_clients.html",
|
||||
}
|
||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||
for name, file := range pageFiles {
|
||||
@@ -501,6 +503,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.
|
||||
@@ -749,8 +755,11 @@ func noDirListing(next http.Handler) http.Handler {
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
// Security: 'unsafe-hashes' with the htmx swap indicator style hash
|
||||
// allows htmx to apply its settling/swapping CSS transitions without
|
||||
// opening the door to arbitrary inline styles.
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'")
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-hashes' 'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='; img-src 'self' data:; font-src 'self'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
@@ -920,6 +929,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
|
||||
|
||||
@@ -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
|
||||
|
||||
86
proto/mcias/v1/sso_client.proto
Normal file
86
proto/mcias/v1/sso_client.proto
Normal file
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
// mcias.js — HTMX event wiring for the MCIAS web UI.
|
||||
// mcias.js — HTMX event wiring and CSP-safe UI helpers for the MCIAS web UI.
|
||||
//
|
||||
// All interactive behavior that would otherwise require inline onclick/onchange
|
||||
// handlers is wired here via data-* attributes so that script-src 'self'
|
||||
// (without 'unsafe-inline') is sufficient.
|
||||
|
||||
// Show server error responses in the global #htmx-error-banner.
|
||||
//
|
||||
@@ -24,3 +28,60 @@ document.body.addEventListener('htmx:afterSwap', function () {
|
||||
banner.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// --- Toggle-form buttons ---
|
||||
//
|
||||
// Usage: <button data-toggle-form="create-form"
|
||||
// data-label-show="Add Item" data-label-hide="Cancel">
|
||||
function toggleForm(btn) {
|
||||
var id = btn.getAttribute('data-toggle-form');
|
||||
var el = document.getElementById(id);
|
||||
if (!el) { return; }
|
||||
var show = el.hidden || el.style.display === 'none';
|
||||
el.hidden = false;
|
||||
el.style.display = show ? '' : 'none';
|
||||
var labelShow = btn.getAttribute('data-label-show') || 'Create';
|
||||
var labelHide = btn.getAttribute('data-label-hide') || 'Cancel';
|
||||
btn.textContent = show ? labelHide : labelShow;
|
||||
}
|
||||
|
||||
// --- Clickable rows ---
|
||||
//
|
||||
// Usage: <tr class="clickable-row" data-href="/audit/123">
|
||||
function handleClickableRow(row) {
|
||||
var href = row.getAttribute('data-href');
|
||||
if (href) { window.location = href; }
|
||||
}
|
||||
|
||||
// --- Policy form tab switching ---
|
||||
//
|
||||
// Usage: <button data-tab="form"> / <button data-tab="json">
|
||||
function showTab(tab) {
|
||||
var formMode = document.getElementById('pf-form-mode');
|
||||
var jsonMode = document.getElementById('pf-json-mode');
|
||||
var formBtn = document.getElementById('tab-form');
|
||||
var jsonBtn = document.getElementById('tab-json');
|
||||
if (!formMode || !jsonMode) { return; }
|
||||
formMode.style.display = tab === 'form' ? '' : 'none';
|
||||
jsonMode.style.display = tab === 'json' ? '' : 'none';
|
||||
if (formBtn) { formBtn.className = tab === 'form' ? 'btn btn-sm btn-secondary' : 'btn btn-sm'; }
|
||||
if (jsonBtn) { jsonBtn.className = tab === 'json' ? 'btn btn-sm btn-secondary' : 'btn btn-sm'; }
|
||||
}
|
||||
|
||||
// --- Auto-wire on DOMContentLoaded ---
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Toggle-form buttons.
|
||||
document.querySelectorAll('[data-toggle-form]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { toggleForm(btn); });
|
||||
});
|
||||
|
||||
// Clickable table rows.
|
||||
document.querySelectorAll('[data-href]').forEach(function (row) {
|
||||
row.addEventListener('click', function () { handleClickableRow(row); });
|
||||
});
|
||||
|
||||
// Tab buttons.
|
||||
document.querySelectorAll('[data-tab]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { showTab(btn.getAttribute('data-tab')); });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,3 +34,8 @@ th { background: #e9ecef; }
|
||||
input, select { padding: 0.4rem; border: 1px solid #ced4da; border-radius: 4px; }
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:hover { background: #e9ecef; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-1 { flex: 1; min-width: 200px; }
|
||||
.flex-2 { flex: 2; min-width: 300px; }
|
||||
.text-center { text-align: center; }
|
||||
.p-2 { padding: 2rem; }
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
<p class="text-muted text-small">Manage user and service accounts</p>
|
||||
</div>
|
||||
<button class="btn btn-primary"
|
||||
onclick="var f=document.getElementById('create-form');f.style.display=f.style.display==='none'?'block':'none'">
|
||||
data-toggle-form="create-form"
|
||||
data-label-show="+ New Account"
|
||||
data-label-hide="Cancel">
|
||||
+ New Account
|
||||
</button>
|
||||
</div>
|
||||
<div id="create-form" class="card mt-2" style="display:none">
|
||||
<div id="create-form" class="card mt-2" hidden>
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Create Account</h2>
|
||||
<form hx-post="/accounts" hx-target="#accounts-tbody" hx-swap="afterbegin">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
@@ -36,7 +38,9 @@
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Create</button>
|
||||
<button class="btn btn-secondary" type="button"
|
||||
onclick="document.getElementById('create-form').style.display='none'">Cancel</button>
|
||||
data-toggle-form="create-form"
|
||||
data-label-show="Cancel"
|
||||
data-label-hide="Cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
|
||||
<li><a href="/audit">Audit</a></li>
|
||||
<li><a href="/policies">Policies</a></li>
|
||||
<li><a href="/sso-clients">SSO Clients</a></li>
|
||||
<li><a href="/pgcreds">PG Creds</a></li>{{else}}<li><a href="/service-accounts">Service Accounts</a></li>{{end}}
|
||||
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
|
||||
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{define "audit_rows"}}
|
||||
{{range .Events}}
|
||||
<tr class="clickable-row" onclick="window.location='/audit/{{.ID}}'">
|
||||
<tr class="clickable-row" data-href="/audit/{{.ID}}">
|
||||
<td class="text-small text-muted"><a href="/audit/{{.ID}}">{{formatTime .EventTime}}</a></td>
|
||||
<td><code style="font-size:.8rem">{{.EventType}}</code></td>
|
||||
<td class="text-small text-muted">{{if .ActorUsername}}{{.ActorUsername}}{{else}}—{{end}}</td>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{{define "policy_form"}}
|
||||
<div style="margin-bottom:.75rem;border-bottom:1px solid var(--border-color);padding-bottom:.5rem;display:flex;gap:.5rem">
|
||||
<button type="button" id="tab-form" class="btn btn-sm btn-secondary"
|
||||
onclick="showTab('form')" style="font-size:.8rem">Form</button>
|
||||
data-tab="form">Form</button>
|
||||
<button type="button" id="tab-json" class="btn btn-sm"
|
||||
onclick="showTab('json')" style="font-size:.8rem;opacity:.6">JSON</button>
|
||||
data-tab="json">JSON</button>
|
||||
</div>
|
||||
<form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin">
|
||||
<div id="pf-form-mode">
|
||||
@@ -121,20 +121,4 @@
|
||||
|
||||
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
|
||||
</form>
|
||||
<script>
|
||||
(function() {
|
||||
var active = 'form';
|
||||
window.showTab = function(tab) {
|
||||
active = tab;
|
||||
document.getElementById('pf-form-mode').style.display = tab === 'form' ? '' : 'none';
|
||||
document.getElementById('pf-json-mode').style.display = tab === 'json' ? '' : 'none';
|
||||
document.getElementById('tab-form').style.opacity = tab === 'form' ? '1' : '.6';
|
||||
document.getElementById('tab-json').style.opacity = tab === 'json' ? '1' : '.6';
|
||||
var formBtn = document.getElementById('tab-form');
|
||||
var jsonBtn = document.getElementById('tab-json');
|
||||
formBtn.className = tab === 'form' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
|
||||
jsonBtn.className = tab === 'json' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
31
web/templates/fragments/sso_client_row.html
Normal file
31
web/templates/fragments/sso_client_row.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{define "sso_client_row"}}
|
||||
<tr id="sso-client-row-{{.ClientID}}">
|
||||
<td><strong>{{.ClientID}}</strong></td>
|
||||
<td><code style="font-size:0.85em">{{.RedirectURI}}</code></td>
|
||||
<td>{{range .Tags}}<span class="badge">{{.}}</span> {{end}}</td>
|
||||
<td>
|
||||
{{if .Enabled}}<span class="badge badge-active">enabled</span>
|
||||
{{else}}<span class="badge badge-inactive">disabled</span>{{end}}
|
||||
</td>
|
||||
<td class="d-flex gap-1">
|
||||
{{if .Enabled}}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-patch="/sso-clients/{{.ClientID}}/toggle"
|
||||
hx-vals='{"enabled":"false"}'
|
||||
hx-target="#sso-client-row-{{.ClientID}}"
|
||||
hx-swap="outerHTML">Disable</button>
|
||||
{{else}}
|
||||
<button class="btn btn-sm btn-primary"
|
||||
hx-patch="/sso-clients/{{.ClientID}}/toggle"
|
||||
hx-vals='{"enabled":"true"}'
|
||||
hx-target="#sso-client-row-{{.ClientID}}"
|
||||
hx-swap="outerHTML">Enable</button>
|
||||
{{end}}
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/sso-clients/{{.ClientID}}"
|
||||
hx-target="#sso-client-row-{{.ClientID}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete SSO client '{{.ClientID}}'? This cannot be undone.">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -82,7 +82,9 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin:0">New Credentials</h2>
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
onclick="var f=document.getElementById('pgcreds-create-form');f.hidden=!f.hidden;this.textContent=f.hidden?'Add Credentials':'Cancel'">Add Credentials</button>
|
||||
data-toggle-form="pgcreds-create-form"
|
||||
data-label-show="Add Credentials"
|
||||
data-label-hide="Cancel">Add Credentials</button>
|
||||
</div>
|
||||
<div id="pgcreds-create-form" hidden>
|
||||
<p class="text-muted text-small" style="margin-bottom:1rem;margin-top:.5rem">
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
<h1>Policy Rules</h1>
|
||||
<p class="text-muted text-small">{{len .Rules}} operator rules (built-in defaults not shown)</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('create-form').style.display='block';this.style.display='none'">
|
||||
<button class="btn btn-primary"
|
||||
data-toggle-form="create-form"
|
||||
data-label-show="Add Rule"
|
||||
data-label-hide="Cancel">
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="create-form" class="card mt-2" style="display:none">
|
||||
<div id="create-form" class="card mt-2" hidden>
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Create Policy Rule</h2>
|
||||
{{template "policy_form" .}}
|
||||
</div>
|
||||
|
||||
57
web/templates/sso_clients.html
Normal file
57
web/templates/sso_clients.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{{define "sso_clients"}}{{template "base" .}}{{end}}
|
||||
{{define "title"}} - SSO Clients{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header d-flex justify-between align-center">
|
||||
<div>
|
||||
<h2>SSO Clients</h2>
|
||||
<p class="text-muted text-small">Registered applications that use MCIAS for single sign-on.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary"
|
||||
data-toggle-form="create-form"
|
||||
data-label-show="Add Client"
|
||||
data-label-hide="Cancel">Add Client</button>
|
||||
</div>
|
||||
|
||||
<div id="create-form" class="card mt-2" hidden>
|
||||
<div class="card-title">Register SSO Client</div>
|
||||
<form hx-post="/sso-clients" hx-target="#sso-clients-tbody" hx-swap="afterbegin">
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<div class="form-group flex-1">
|
||||
<label class="form-label">Client ID / Service Name</label>
|
||||
<input class="form-control" type="text" name="client_id" required placeholder="e.g. mcr">
|
||||
</div>
|
||||
<div class="form-group flex-2">
|
||||
<label class="form-label">Redirect URI</label>
|
||||
<input class="form-control" type="url" name="redirect_uri" required placeholder="https://service.example.com/sso/callback">
|
||||
</div>
|
||||
<div class="form-group flex-1">
|
||||
<label class="form-label">Tags <span class="text-muted text-small">(comma-separated)</span></label>
|
||||
<input class="form-control" type="text" name="tags" placeholder="env:prod,tier:web">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Redirect URI</th>
|
||||
<th>Tags</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sso-clients-tbody">
|
||||
{{range .Clients}}
|
||||
{{template "sso_client_row" .}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{if eq (len .Clients) 0}}<p class="text-muted text-center p-2">No SSO clients registered.</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user