From 4114d087ce1de6dd81eafe20a7fa5c1ac4cd0e12 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Thu, 12 Mar 2026 20:55:49 -0700 Subject: [PATCH] Add granular role grant/revoke endpoints to REST and gRPC APIs - Add POST /v1/accounts/{id}/roles and DELETE /v1/accounts/{id}/roles/{role} REST endpoints - Add GrantRole and RevokeRole RPCs to AccountService in gRPC API - Update OpenAPI specification with new endpoints - Add grant and revoke subcommands to mciasctl - Add grant and revoke subcommands to mciasgrpcctl - Regenerate proto files with new message types and RPCs - Implement gRPC server methods for granular role management - All existing tests pass; build verified with goimports Security: Role changes are audited via EventRoleGranted and EventRoleRevoked events, consistent with existing SetRoles implementation. --- cmd/mciasctl/main.go | 43 +++- cmd/mciasgrpcctl/main.go | 60 +++++- gen/mcias/v1/account.pb.go | 283 ++++++++++++++++++++++---- gen/mcias/v1/account_grpc.pb.go | 78 ++++++- internal/grpcserver/accountservice.go | 70 +++++++ internal/server/server.go | 68 +++++++ openapi.yaml | 70 +++++++ proto/mcias/v1/account.proto | 20 ++ 8 files changed, 645 insertions(+), 47 deletions(-) diff --git a/cmd/mciasctl/main.go b/cmd/mciasctl/main.go index f25e3fd..b14a911 100644 --- a/cmd/mciasctl/main.go +++ b/cmd/mciasctl/main.go @@ -28,6 +28,8 @@ // // role list -id UUID // role set -id UUID -roles role1,role2,... +// role grant -id UUID -role ROLE +// role revoke -id UUID -role ROLE // // token issue -id UUID // token revoke -jti JTI @@ -386,13 +388,17 @@ func (c *controller) accountSetPassword(args []string) { func (c *controller) runRole(args []string) { if len(args) == 0 { - fatalf("role requires a subcommand: list, set") + fatalf("role requires a subcommand: list, set, grant, revoke") } switch args[0] { case "list": c.roleList(args[1:]) case "set": c.roleSet(args[1:]) + case "grant": + c.roleGrant(args[1:]) + case "revoke": + c.roleRevoke(args[1:]) default: fatalf("unknown role subcommand %q", args[0]) } @@ -437,6 +443,41 @@ func (c *controller) roleSet(args []string) { fmt.Printf("roles set: %v\n", roles) } +func (c *controller) roleGrant(args []string) { + fs := flag.NewFlagSet("role grant", flag.ExitOnError) + id := fs.String("id", "", "account UUID (required)") + role := fs.String("role", "", "role name (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("role grant: -id is required") + } + if *role == "" { + fatalf("role grant: -role is required") + } + + body := map[string]string{"role": *role} + c.doRequest("POST", "/v1/accounts/"+*id+"/roles", body, nil) + fmt.Printf("role granted: %s\n", *role) +} + +func (c *controller) roleRevoke(args []string) { + fs := flag.NewFlagSet("role revoke", flag.ExitOnError) + id := fs.String("id", "", "account UUID (required)") + role := fs.String("role", "", "role name (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("role revoke: -id is required") + } + if *role == "" { + fatalf("role revoke: -role is required") + } + + c.doRequest("DELETE", "/v1/accounts/"+*id+"/roles/"+*role, nil, nil) + fmt.Printf("role revoked: %s\n", *role) +} + // ---- token subcommands ---- func (c *controller) runToken(args []string) { diff --git a/cmd/mciasgrpcctl/main.go b/cmd/mciasgrpcctl/main.go index 71be05d..f7552ef 100644 --- a/cmd/mciasgrpcctl/main.go +++ b/cmd/mciasgrpcctl/main.go @@ -28,8 +28,10 @@ // account update -id UUID -status active|inactive // account delete -id UUID // -// role list -id UUID -// role set -id UUID -roles role1,role2,... +// role list -id UUID +// role set -id UUID -roles role1,role2,... +// role grant -id UUID -role ROLE +// role revoke -id UUID -role ROLE // // token validate -token TOKEN // token issue -id UUID @@ -392,13 +394,17 @@ func (c *controller) accountDelete(args []string) { func (c *controller) runRole(args []string) { if len(args) == 0 { - fatalf("role requires a subcommand: list, set") + fatalf("role requires a subcommand: list, set, grant, revoke") } switch args[0] { case "list": c.roleList(args[1:]) case "set": c.roleSet(args[1:]) + case "grant": + c.roleGrant(args[1:]) + case "revoke": + c.roleRevoke(args[1:]) default: fatalf("unknown role subcommand %q", args[0]) } @@ -455,6 +461,54 @@ func (c *controller) roleSet(args []string) { fmt.Printf("roles set: %v\n", roles) } +func (c *controller) roleGrant(args []string) { + fs := flag.NewFlagSet("role grant", flag.ExitOnError) + id := fs.String("id", "", "account UUID (required)") + role := fs.String("role", "", "role name (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("role grant: -id is required") + } + if *role == "" { + fatalf("role grant: -role is required") + } + + cl := mciasv1.NewAccountServiceClient(c.conn) + ctx, cancel := c.callCtx() + defer cancel() + + _, err := cl.GrantRole(ctx, &mciasv1.GrantRoleRequest{Id: *id, Role: *role}) + if err != nil { + fatalf("role grant: %v", err) + } + fmt.Printf("role granted: %s\n", *role) +} + +func (c *controller) roleRevoke(args []string) { + fs := flag.NewFlagSet("role revoke", flag.ExitOnError) + id := fs.String("id", "", "account UUID (required)") + role := fs.String("role", "", "role name (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("role revoke: -id is required") + } + if *role == "" { + fatalf("role revoke: -role is required") + } + + cl := mciasv1.NewAccountServiceClient(c.conn) + ctx, cancel := c.callCtx() + defer cancel() + + _, err := cl.RevokeRole(ctx, &mciasv1.RevokeRoleRequest{Id: *id, Role: *role}) + if err != nil { + fatalf("role revoke: %v", err) + } + fmt.Printf("role revoked: %s\n", *role) +} + // ---- token subcommands ---- func (c *controller) runToken(args []string) { diff --git a/gen/mcias/v1/account.pb.go b/gen/mcias/v1/account.pb.go index c696ece..d7b75ae 100644 --- a/gen/mcias/v1/account.pb.go +++ b/gen/mcias/v1/account.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: mcias/v1/account.proto package mciasv1 @@ -654,6 +654,186 @@ func (*SetRolesResponse) Descriptor() ([]byte, []int) { return file_mcias_v1_account_proto_rawDescGZIP(), []int{13} } +// GrantRoleRequest adds a single role to an account. +type GrantRoleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID + Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` // role name + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantRoleRequest) Reset() { + *x = GrantRoleRequest{} + mi := &file_mcias_v1_account_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantRoleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantRoleRequest) ProtoMessage() {} + +func (x *GrantRoleRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_account_proto_msgTypes[14] + 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 GrantRoleRequest.ProtoReflect.Descriptor instead. +func (*GrantRoleRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_account_proto_rawDescGZIP(), []int{14} +} + +func (x *GrantRoleRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GrantRoleRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +// GrantRoleResponse confirms the grant. +type GrantRoleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantRoleResponse) Reset() { + *x = GrantRoleResponse{} + mi := &file_mcias_v1_account_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantRoleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantRoleResponse) ProtoMessage() {} + +func (x *GrantRoleResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_account_proto_msgTypes[15] + 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 GrantRoleResponse.ProtoReflect.Descriptor instead. +func (*GrantRoleResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_account_proto_rawDescGZIP(), []int{15} +} + +// RevokeRoleRequest removes a single role from an account. +type RevokeRoleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID + Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` // role name + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeRoleRequest) Reset() { + *x = RevokeRoleRequest{} + mi := &file_mcias_v1_account_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeRoleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeRoleRequest) ProtoMessage() {} + +func (x *RevokeRoleRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_account_proto_msgTypes[16] + 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 RevokeRoleRequest.ProtoReflect.Descriptor instead. +func (*RevokeRoleRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_account_proto_rawDescGZIP(), []int{16} +} + +func (x *RevokeRoleRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *RevokeRoleRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +// RevokeRoleResponse confirms the revocation. +type RevokeRoleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeRoleResponse) Reset() { + *x = RevokeRoleResponse{} + mi := &file_mcias_v1_account_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeRoleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeRoleResponse) ProtoMessage() {} + +func (x *RevokeRoleResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_account_proto_msgTypes[17] + 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 RevokeRoleResponse.ProtoReflect.Descriptor instead. +func (*RevokeRoleResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_account_proto_rawDescGZIP(), []int{17} +} + // GetPGCredsRequest identifies an account by UUID. type GetPGCredsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -664,7 +844,7 @@ type GetPGCredsRequest struct { func (x *GetPGCredsRequest) Reset() { *x = GetPGCredsRequest{} - mi := &file_mcias_v1_account_proto_msgTypes[14] + mi := &file_mcias_v1_account_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -676,7 +856,7 @@ func (x *GetPGCredsRequest) String() string { func (*GetPGCredsRequest) ProtoMessage() {} func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message { - mi := &file_mcias_v1_account_proto_msgTypes[14] + mi := &file_mcias_v1_account_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -689,7 +869,7 @@ func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPGCredsRequest.ProtoReflect.Descriptor instead. func (*GetPGCredsRequest) Descriptor() ([]byte, []int) { - return file_mcias_v1_account_proto_rawDescGZIP(), []int{14} + return file_mcias_v1_account_proto_rawDescGZIP(), []int{18} } func (x *GetPGCredsRequest) GetId() string { @@ -710,7 +890,7 @@ type GetPGCredsResponse struct { func (x *GetPGCredsResponse) Reset() { *x = GetPGCredsResponse{} - mi := &file_mcias_v1_account_proto_msgTypes[15] + mi := &file_mcias_v1_account_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -722,7 +902,7 @@ func (x *GetPGCredsResponse) String() string { func (*GetPGCredsResponse) ProtoMessage() {} func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message { - mi := &file_mcias_v1_account_proto_msgTypes[15] + mi := &file_mcias_v1_account_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -735,7 +915,7 @@ func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPGCredsResponse.ProtoReflect.Descriptor instead. func (*GetPGCredsResponse) Descriptor() ([]byte, []int) { - return file_mcias_v1_account_proto_rawDescGZIP(), []int{15} + return file_mcias_v1_account_proto_rawDescGZIP(), []int{19} } func (x *GetPGCredsResponse) GetCreds() *PGCreds { @@ -756,7 +936,7 @@ type SetPGCredsRequest struct { func (x *SetPGCredsRequest) Reset() { *x = SetPGCredsRequest{} - mi := &file_mcias_v1_account_proto_msgTypes[16] + mi := &file_mcias_v1_account_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -768,7 +948,7 @@ func (x *SetPGCredsRequest) String() string { func (*SetPGCredsRequest) ProtoMessage() {} func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message { - mi := &file_mcias_v1_account_proto_msgTypes[16] + mi := &file_mcias_v1_account_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -781,7 +961,7 @@ func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetPGCredsRequest.ProtoReflect.Descriptor instead. func (*SetPGCredsRequest) Descriptor() ([]byte, []int) { - return file_mcias_v1_account_proto_rawDescGZIP(), []int{16} + return file_mcias_v1_account_proto_rawDescGZIP(), []int{20} } func (x *SetPGCredsRequest) GetId() string { @@ -807,7 +987,7 @@ type SetPGCredsResponse struct { func (x *SetPGCredsResponse) Reset() { *x = SetPGCredsResponse{} - mi := &file_mcias_v1_account_proto_msgTypes[17] + mi := &file_mcias_v1_account_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -819,7 +999,7 @@ func (x *SetPGCredsResponse) String() string { func (*SetPGCredsResponse) ProtoMessage() {} func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message { - mi := &file_mcias_v1_account_proto_msgTypes[17] + mi := &file_mcias_v1_account_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -832,7 +1012,7 @@ func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetPGCredsResponse.ProtoReflect.Descriptor instead. func (*SetPGCredsResponse) Descriptor() ([]byte, []int) { - return file_mcias_v1_account_proto_rawDescGZIP(), []int{17} + return file_mcias_v1_account_proto_rawDescGZIP(), []int{21} } var File_mcias_v1_account_proto protoreflect.FileDescriptor @@ -867,7 +1047,15 @@ const file_mcias_v1_account_proto_rawDesc = "" + "\x0fSetRolesRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x05roles\x18\x02 \x03(\tR\x05roles\"\x12\n" + - "\x10SetRolesResponse\"#\n" + + "\x10SetRolesResponse\"6\n" + + "\x10GrantRoleRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04role\x18\x02 \x01(\tR\x04role\"\x13\n" + + "\x11GrantRoleResponse\"7\n" + + "\x11RevokeRoleRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04role\x18\x02 \x01(\tR\x04role\"\x14\n" + + "\x12RevokeRoleResponse\"#\n" + "\x11GetPGCredsRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"=\n" + "\x12GetPGCredsResponse\x12'\n" + @@ -875,7 +1063,7 @@ const file_mcias_v1_account_proto_rawDesc = "" + "\x11SetPGCredsRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12'\n" + "\x05creds\x18\x02 \x01(\v2\x11.mcias.v1.PGCredsR\x05creds\"\x14\n" + - "\x12SetPGCredsResponse2\xa4\x04\n" + + "\x12SetPGCredsResponse2\xb3\x05\n" + "\x0eAccountService\x12M\n" + "\fListAccounts\x12\x1d.mcias.v1.ListAccountsRequest\x1a\x1e.mcias.v1.ListAccountsResponse\x12P\n" + "\rCreateAccount\x12\x1e.mcias.v1.CreateAccountRequest\x1a\x1f.mcias.v1.CreateAccountResponse\x12G\n" + @@ -884,7 +1072,10 @@ const file_mcias_v1_account_proto_rawDesc = "" + "\rUpdateAccount\x12\x1e.mcias.v1.UpdateAccountRequest\x1a\x1f.mcias.v1.UpdateAccountResponse\x12P\n" + "\rDeleteAccount\x12\x1e.mcias.v1.DeleteAccountRequest\x1a\x1f.mcias.v1.DeleteAccountResponse\x12A\n" + "\bGetRoles\x12\x19.mcias.v1.GetRolesRequest\x1a\x1a.mcias.v1.GetRolesResponse\x12A\n" + - "\bSetRoles\x12\x19.mcias.v1.SetRolesRequest\x1a\x1a.mcias.v1.SetRolesResponse2\xa5\x01\n" + + "\bSetRoles\x12\x19.mcias.v1.SetRolesRequest\x1a\x1a.mcias.v1.SetRolesResponse\x12D\n" + + "\tGrantRole\x12\x1a.mcias.v1.GrantRoleRequest\x1a\x1b.mcias.v1.GrantRoleResponse\x12G\n" + + "\n" + + "RevokeRole\x12\x1b.mcias.v1.RevokeRoleRequest\x1a\x1c.mcias.v1.RevokeRoleResponse2\xa5\x01\n" + "\x11CredentialService\x12G\n" + "\n" + "GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" + @@ -903,7 +1094,7 @@ func file_mcias_v1_account_proto_rawDescGZIP() []byte { return file_mcias_v1_account_proto_rawDescData } -var file_mcias_v1_account_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_mcias_v1_account_proto_msgTypes = make([]protoimpl.MessageInfo, 22) var file_mcias_v1_account_proto_goTypes = []any{ (*ListAccountsRequest)(nil), // 0: mcias.v1.ListAccountsRequest (*ListAccountsResponse)(nil), // 1: mcias.v1.ListAccountsResponse @@ -919,19 +1110,23 @@ var file_mcias_v1_account_proto_goTypes = []any{ (*GetRolesResponse)(nil), // 11: mcias.v1.GetRolesResponse (*SetRolesRequest)(nil), // 12: mcias.v1.SetRolesRequest (*SetRolesResponse)(nil), // 13: mcias.v1.SetRolesResponse - (*GetPGCredsRequest)(nil), // 14: mcias.v1.GetPGCredsRequest - (*GetPGCredsResponse)(nil), // 15: mcias.v1.GetPGCredsResponse - (*SetPGCredsRequest)(nil), // 16: mcias.v1.SetPGCredsRequest - (*SetPGCredsResponse)(nil), // 17: mcias.v1.SetPGCredsResponse - (*Account)(nil), // 18: mcias.v1.Account - (*PGCreds)(nil), // 19: mcias.v1.PGCreds + (*GrantRoleRequest)(nil), // 14: mcias.v1.GrantRoleRequest + (*GrantRoleResponse)(nil), // 15: mcias.v1.GrantRoleResponse + (*RevokeRoleRequest)(nil), // 16: mcias.v1.RevokeRoleRequest + (*RevokeRoleResponse)(nil), // 17: mcias.v1.RevokeRoleResponse + (*GetPGCredsRequest)(nil), // 18: mcias.v1.GetPGCredsRequest + (*GetPGCredsResponse)(nil), // 19: mcias.v1.GetPGCredsResponse + (*SetPGCredsRequest)(nil), // 20: mcias.v1.SetPGCredsRequest + (*SetPGCredsResponse)(nil), // 21: mcias.v1.SetPGCredsResponse + (*Account)(nil), // 22: mcias.v1.Account + (*PGCreds)(nil), // 23: mcias.v1.PGCreds } var file_mcias_v1_account_proto_depIdxs = []int32{ - 18, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account - 18, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account - 18, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account - 19, // 3: mcias.v1.GetPGCredsResponse.creds:type_name -> mcias.v1.PGCreds - 19, // 4: mcias.v1.SetPGCredsRequest.creds:type_name -> mcias.v1.PGCreds + 22, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account + 22, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account + 22, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account + 23, // 3: mcias.v1.GetPGCredsResponse.creds:type_name -> mcias.v1.PGCreds + 23, // 4: mcias.v1.SetPGCredsRequest.creds:type_name -> mcias.v1.PGCreds 0, // 5: mcias.v1.AccountService.ListAccounts:input_type -> mcias.v1.ListAccountsRequest 2, // 6: mcias.v1.AccountService.CreateAccount:input_type -> mcias.v1.CreateAccountRequest 4, // 7: mcias.v1.AccountService.GetAccount:input_type -> mcias.v1.GetAccountRequest @@ -939,19 +1134,23 @@ var file_mcias_v1_account_proto_depIdxs = []int32{ 8, // 9: mcias.v1.AccountService.DeleteAccount:input_type -> mcias.v1.DeleteAccountRequest 10, // 10: mcias.v1.AccountService.GetRoles:input_type -> mcias.v1.GetRolesRequest 12, // 11: mcias.v1.AccountService.SetRoles:input_type -> mcias.v1.SetRolesRequest - 14, // 12: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest - 16, // 13: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest - 1, // 14: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse - 3, // 15: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse - 5, // 16: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse - 7, // 17: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse - 9, // 18: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse - 11, // 19: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse - 13, // 20: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse - 15, // 21: mcias.v1.CredentialService.GetPGCreds:output_type -> mcias.v1.GetPGCredsResponse - 17, // 22: mcias.v1.CredentialService.SetPGCreds:output_type -> mcias.v1.SetPGCredsResponse - 14, // [14:23] is the sub-list for method output_type - 5, // [5:14] is the sub-list for method input_type + 14, // 12: mcias.v1.AccountService.GrantRole:input_type -> mcias.v1.GrantRoleRequest + 16, // 13: mcias.v1.AccountService.RevokeRole:input_type -> mcias.v1.RevokeRoleRequest + 18, // 14: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest + 20, // 15: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest + 1, // 16: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse + 3, // 17: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse + 5, // 18: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse + 7, // 19: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse + 9, // 20: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse + 11, // 21: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse + 13, // 22: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse + 15, // 23: mcias.v1.AccountService.GrantRole:output_type -> mcias.v1.GrantRoleResponse + 17, // 24: mcias.v1.AccountService.RevokeRole:output_type -> mcias.v1.RevokeRoleResponse + 19, // 25: mcias.v1.CredentialService.GetPGCreds:output_type -> mcias.v1.GetPGCredsResponse + 21, // 26: mcias.v1.CredentialService.SetPGCreds:output_type -> mcias.v1.SetPGCredsResponse + 16, // [16:27] is the sub-list for method output_type + 5, // [5:16] is the sub-list for method input_type 5, // [5:5] is the sub-list for extension type_name 5, // [5:5] is the sub-list for extension extendee 0, // [0:5] is the sub-list for field type_name @@ -969,7 +1168,7 @@ func file_mcias_v1_account_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_account_proto_rawDesc), len(file_mcias_v1_account_proto_rawDesc)), NumEnums: 0, - NumMessages: 18, + NumMessages: 22, NumExtensions: 0, NumServices: 2, }, diff --git a/gen/mcias/v1/account_grpc.pb.go b/gen/mcias/v1/account_grpc.pb.go index 2127f1e..4311335 100644 --- a/gen/mcias/v1/account_grpc.pb.go +++ b/gen/mcias/v1/account_grpc.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v3.20.3 +// - protoc v6.33.4 // source: mcias/v1/account.proto package mciasv1 @@ -29,6 +29,8 @@ const ( AccountService_DeleteAccount_FullMethodName = "/mcias.v1.AccountService/DeleteAccount" AccountService_GetRoles_FullMethodName = "/mcias.v1.AccountService/GetRoles" AccountService_SetRoles_FullMethodName = "/mcias.v1.AccountService/SetRoles" + AccountService_GrantRole_FullMethodName = "/mcias.v1.AccountService/GrantRole" + AccountService_RevokeRole_FullMethodName = "/mcias.v1.AccountService/RevokeRole" ) // AccountServiceClient is the client API for AccountService service. @@ -44,6 +46,8 @@ type AccountServiceClient interface { DeleteAccount(ctx context.Context, in *DeleteAccountRequest, opts ...grpc.CallOption) (*DeleteAccountResponse, error) GetRoles(ctx context.Context, in *GetRolesRequest, opts ...grpc.CallOption) (*GetRolesResponse, error) SetRoles(ctx context.Context, in *SetRolesRequest, opts ...grpc.CallOption) (*SetRolesResponse, error) + GrantRole(ctx context.Context, in *GrantRoleRequest, opts ...grpc.CallOption) (*GrantRoleResponse, error) + RevokeRole(ctx context.Context, in *RevokeRoleRequest, opts ...grpc.CallOption) (*RevokeRoleResponse, error) } type accountServiceClient struct { @@ -124,6 +128,26 @@ func (c *accountServiceClient) SetRoles(ctx context.Context, in *SetRolesRequest return out, nil } +func (c *accountServiceClient) GrantRole(ctx context.Context, in *GrantRoleRequest, opts ...grpc.CallOption) (*GrantRoleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GrantRoleResponse) + err := c.cc.Invoke(ctx, AccountService_GrantRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *accountServiceClient) RevokeRole(ctx context.Context, in *RevokeRoleRequest, opts ...grpc.CallOption) (*RevokeRoleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevokeRoleResponse) + err := c.cc.Invoke(ctx, AccountService_RevokeRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AccountServiceServer is the server API for AccountService service. // All implementations must embed UnimplementedAccountServiceServer // for forward compatibility. @@ -137,6 +161,8 @@ type AccountServiceServer interface { DeleteAccount(context.Context, *DeleteAccountRequest) (*DeleteAccountResponse, error) GetRoles(context.Context, *GetRolesRequest) (*GetRolesResponse, error) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) + GrantRole(context.Context, *GrantRoleRequest) (*GrantRoleResponse, error) + RevokeRole(context.Context, *RevokeRoleRequest) (*RevokeRoleResponse, error) mustEmbedUnimplementedAccountServiceServer() } @@ -168,6 +194,12 @@ func (UnimplementedAccountServiceServer) GetRoles(context.Context, *GetRolesRequ func (UnimplementedAccountServiceServer) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) { return nil, status.Error(codes.Unimplemented, "method SetRoles not implemented") } +func (UnimplementedAccountServiceServer) GrantRole(context.Context, *GrantRoleRequest) (*GrantRoleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GrantRole not implemented") +} +func (UnimplementedAccountServiceServer) RevokeRole(context.Context, *RevokeRoleRequest) (*RevokeRoleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RevokeRole not implemented") +} func (UnimplementedAccountServiceServer) mustEmbedUnimplementedAccountServiceServer() {} func (UnimplementedAccountServiceServer) testEmbeddedByValue() {} @@ -315,6 +347,42 @@ func _AccountService_SetRoles_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _AccountService_GrantRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GrantRoleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AccountServiceServer).GrantRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AccountService_GrantRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AccountServiceServer).GrantRole(ctx, req.(*GrantRoleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AccountService_RevokeRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeRoleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AccountServiceServer).RevokeRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AccountService_RevokeRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AccountServiceServer).RevokeRole(ctx, req.(*RevokeRoleRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AccountService_ServiceDesc is the grpc.ServiceDesc for AccountService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -350,6 +418,14 @@ var AccountService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SetRoles", Handler: _AccountService_SetRoles_Handler, }, + { + MethodName: "GrantRole", + Handler: _AccountService_GrantRole_Handler, + }, + { + MethodName: "RevokeRole", + Handler: _AccountService_RevokeRole_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "mcias/v1/account.proto", diff --git a/internal/grpcserver/accountservice.go b/internal/grpcserver/accountservice.go index b62bcf7..db67bc5 100644 --- a/internal/grpcserver/accountservice.go +++ b/internal/grpcserver/accountservice.go @@ -227,3 +227,73 @@ func (a *accountServiceServer) SetRoles(ctx context.Context, req *mciasv1.SetRol fmt.Sprintf(`{"roles":%v}`, req.Roles)) return &mciasv1.SetRolesResponse{}, nil } + +// GrantRole adds a single role to an account. Admin only. +func (a *accountServiceServer) GrantRole(ctx context.Context, req *mciasv1.GrantRoleRequest) (*mciasv1.GrantRoleResponse, error) { + if err := a.s.requireAdmin(ctx); err != nil { + return nil, err + } + if req.Id == "" { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + if req.Role == "" { + return nil, status.Error(codes.InvalidArgument, "role is required") + } + acct, err := a.s.db.GetAccountByUUID(req.Id) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, status.Error(codes.NotFound, "account not found") + } + return nil, status.Error(codes.Internal, "internal error") + } + + actorClaims := claimsFromContext(ctx) + var grantedBy *int64 + if actorClaims != nil { + if actor, err := a.s.db.GetAccountByUUID(actorClaims.Subject); err == nil { + grantedBy = &actor.ID + } + } + + if err := a.s.db.GrantRole(acct.ID, req.Role, grantedBy); err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid role") + } + a.s.db.WriteAuditEvent(model.EventRoleGranted, grantedBy, &acct.ID, peerIP(ctx), //nolint:errcheck + fmt.Sprintf(`{"role":"%s"}`, req.Role)) + return &mciasv1.GrantRoleResponse{}, nil +} + +// RevokeRole removes a single role from an account. Admin only. +func (a *accountServiceServer) RevokeRole(ctx context.Context, req *mciasv1.RevokeRoleRequest) (*mciasv1.RevokeRoleResponse, error) { + if err := a.s.requireAdmin(ctx); err != nil { + return nil, err + } + if req.Id == "" { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + if req.Role == "" { + return nil, status.Error(codes.InvalidArgument, "role is required") + } + acct, err := a.s.db.GetAccountByUUID(req.Id) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, status.Error(codes.NotFound, "account not found") + } + return nil, status.Error(codes.Internal, "internal error") + } + + actorClaims := claimsFromContext(ctx) + var revokedBy *int64 + if actorClaims != nil { + if actor, err := a.s.db.GetAccountByUUID(actorClaims.Subject); err == nil { + revokedBy = &actor.ID + } + } + + if err := a.s.db.RevokeRole(acct.ID, req.Role); err != nil { + return nil, status.Error(codes.Internal, "internal error") + } + a.s.db.WriteAuditEvent(model.EventRoleRevoked, revokedBy, &acct.ID, peerIP(ctx), //nolint:errcheck + fmt.Sprintf(`{"role":"%s"}`, req.Role)) + return &mciasv1.RevokeRoleResponse{}, nil +} diff --git a/internal/server/server.go b/internal/server/server.go index b3fe128..aa135a1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -130,6 +130,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount))) mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles))) mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles))) + mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole))) + mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole))) mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds))) mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds))) mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) @@ -666,6 +668,10 @@ type setRolesRequest struct { Roles []string `json:"roles"` } +type grantRoleRequest struct { + Role string `json:"role"` +} + func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { @@ -710,6 +716,68 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (s *Server) handleGrantRole(w http.ResponseWriter, r *http.Request) { + acct, ok := s.loadAccount(w, r) + if !ok { + return + } + + var req grantRoleRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Role == "" { + middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request") + return + } + + actor := middleware.ClaimsFromContext(r.Context()) + var grantedBy *int64 + if actor != nil { + if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil { + grantedBy = &a.ID + } + } + + if err := s.db.GrantRole(acct.ID, req.Role, grantedBy); err != nil { + middleware.WriteError(w, http.StatusBadRequest, "invalid role", "bad_request") + return + } + + s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, req.Role)) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleRevokeRole(w http.ResponseWriter, r *http.Request) { + acct, ok := s.loadAccount(w, r) + if !ok { + return + } + + role := r.PathValue("role") + if role == "" { + middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request") + return + } + + actor := middleware.ClaimsFromContext(r.Context()) + var revokedBy *int64 + if actor != nil { + if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil { + revokedBy = &a.ID + } + } + + if err := s.db.RevokeRole(acct.ID, role); err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, role)) + w.WriteHeader(http.StatusNoContent) +} + // ---- TOTP endpoints ---- type totpEnrollResponse struct { diff --git a/openapi.yaml b/openapi.yaml index 10a01af..da17178 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -995,6 +995,76 @@ paths: "404": $ref: "#/components/responses/NotFound" + post: + summary: Grant a role to an account (admin) + description: | + Add a single role to an account's role set. If the role already exists, + this is a no-op. Roles take effect in the **next** token issued or + renewed; existing tokens continue to carry the roles embedded at + issuance time. + operationId: grantRole + tags: [Admin — Accounts] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + example: editor + responses: + "204": + description: Role granted. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/accounts/{id}/roles/{role}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + - name: role + in: path + required: true + schema: + type: string + example: editor + + delete: + summary: Revoke a role from an account (admin) + description: | + Remove a single role from an account's role set. Roles take effect in + the **next** token issued or renewed; existing tokens continue to carry + the roles embedded at issuance time. + operationId: revokeRole + tags: [Admin — Accounts] + security: + - bearerAuth: [] + responses: + "204": + description: Role revoked. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /v1/accounts/{id}/pgcreds: parameters: - name: id diff --git a/proto/mcias/v1/account.proto b/proto/mcias/v1/account.proto index 1324d48..84a1f59 100644 --- a/proto/mcias/v1/account.proto +++ b/proto/mcias/v1/account.proto @@ -78,6 +78,24 @@ message SetRolesRequest { // SetRolesResponse confirms the update. message SetRolesResponse {} +// GrantRoleRequest adds a single role to an account. +message GrantRoleRequest { + string id = 1; // UUID + string role = 2; // role name +} + +// GrantRoleResponse confirms the grant. +message GrantRoleResponse {} + +// RevokeRoleRequest removes a single role from an account. +message RevokeRoleRequest { + string id = 1; // UUID + string role = 2; // role name +} + +// RevokeRoleResponse confirms the revocation. +message RevokeRoleResponse {} + // AccountService manages accounts and roles. All RPCs require admin role. service AccountService { rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse); @@ -87,6 +105,8 @@ service AccountService { rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse); rpc GetRoles(GetRolesRequest) returns (GetRolesResponse); rpc SetRoles(SetRolesRequest) returns (SetRolesResponse); + rpc GrantRole(GrantRoleRequest) returns (GrantRoleResponse); + rpc RevokeRole(RevokeRoleRequest) returns (RevokeRoleResponse); } // --- PG credentials ---