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.
This commit is contained in:
2026-03-12 20:55:49 -07:00
parent 7ede54afb2
commit 4114d087ce
8 changed files with 645 additions and 47 deletions

View File

@@ -28,6 +28,8 @@
// //
// role list -id UUID // role list -id UUID
// role set -id UUID -roles role1,role2,... // role set -id UUID -roles role1,role2,...
// role grant -id UUID -role ROLE
// role revoke -id UUID -role ROLE
// //
// token issue -id UUID // token issue -id UUID
// token revoke -jti JTI // token revoke -jti JTI
@@ -386,13 +388,17 @@ func (c *controller) accountSetPassword(args []string) {
func (c *controller) runRole(args []string) { func (c *controller) runRole(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("role requires a subcommand: list, set") fatalf("role requires a subcommand: list, set, grant, revoke")
} }
switch args[0] { switch args[0] {
case "list": case "list":
c.roleList(args[1:]) c.roleList(args[1:])
case "set": case "set":
c.roleSet(args[1:]) c.roleSet(args[1:])
case "grant":
c.roleGrant(args[1:])
case "revoke":
c.roleRevoke(args[1:])
default: default:
fatalf("unknown role subcommand %q", args[0]) fatalf("unknown role subcommand %q", args[0])
} }
@@ -437,6 +443,41 @@ func (c *controller) roleSet(args []string) {
fmt.Printf("roles set: %v\n", roles) 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 ---- // ---- token subcommands ----
func (c *controller) runToken(args []string) { func (c *controller) runToken(args []string) {

View File

@@ -28,8 +28,10 @@
// account update -id UUID -status active|inactive // account update -id UUID -status active|inactive
// account delete -id UUID // account delete -id UUID
// //
// role list -id UUID // role list -id UUID
// role set -id UUID -roles role1,role2,... // role set -id UUID -roles role1,role2,...
// role grant -id UUID -role ROLE
// role revoke -id UUID -role ROLE
// //
// token validate -token TOKEN // token validate -token TOKEN
// token issue -id UUID // token issue -id UUID
@@ -392,13 +394,17 @@ func (c *controller) accountDelete(args []string) {
func (c *controller) runRole(args []string) { func (c *controller) runRole(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("role requires a subcommand: list, set") fatalf("role requires a subcommand: list, set, grant, revoke")
} }
switch args[0] { switch args[0] {
case "list": case "list":
c.roleList(args[1:]) c.roleList(args[1:])
case "set": case "set":
c.roleSet(args[1:]) c.roleSet(args[1:])
case "grant":
c.roleGrant(args[1:])
case "revoke":
c.roleRevoke(args[1:])
default: default:
fatalf("unknown role subcommand %q", args[0]) fatalf("unknown role subcommand %q", args[0])
} }
@@ -455,6 +461,54 @@ func (c *controller) roleSet(args []string) {
fmt.Printf("roles set: %v\n", roles) 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 ---- // ---- token subcommands ----
func (c *controller) runToken(args []string) { func (c *controller) runToken(args []string) {

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.33.4
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1
@@ -654,6 +654,186 @@ func (*SetRolesResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_account_proto_rawDescGZIP(), []int{13} 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. // GetPGCredsRequest identifies an account by UUID.
type GetPGCredsRequest struct { type GetPGCredsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -664,7 +844,7 @@ type GetPGCredsRequest struct {
func (x *GetPGCredsRequest) Reset() { func (x *GetPGCredsRequest) Reset() {
*x = GetPGCredsRequest{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -676,7 +856,7 @@ func (x *GetPGCredsRequest) String() string {
func (*GetPGCredsRequest) ProtoMessage() {} func (*GetPGCredsRequest) ProtoMessage() {}
func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -689,7 +869,7 @@ func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPGCredsRequest.ProtoReflect.Descriptor instead. // Deprecated: Use GetPGCredsRequest.ProtoReflect.Descriptor instead.
func (*GetPGCredsRequest) Descriptor() ([]byte, []int) { 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 { func (x *GetPGCredsRequest) GetId() string {
@@ -710,7 +890,7 @@ type GetPGCredsResponse struct {
func (x *GetPGCredsResponse) Reset() { func (x *GetPGCredsResponse) Reset() {
*x = GetPGCredsResponse{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -722,7 +902,7 @@ func (x *GetPGCredsResponse) String() string {
func (*GetPGCredsResponse) ProtoMessage() {} func (*GetPGCredsResponse) ProtoMessage() {}
func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -735,7 +915,7 @@ func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPGCredsResponse.ProtoReflect.Descriptor instead. // Deprecated: Use GetPGCredsResponse.ProtoReflect.Descriptor instead.
func (*GetPGCredsResponse) Descriptor() ([]byte, []int) { 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 { func (x *GetPGCredsResponse) GetCreds() *PGCreds {
@@ -756,7 +936,7 @@ type SetPGCredsRequest struct {
func (x *SetPGCredsRequest) Reset() { func (x *SetPGCredsRequest) Reset() {
*x = SetPGCredsRequest{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -768,7 +948,7 @@ func (x *SetPGCredsRequest) String() string {
func (*SetPGCredsRequest) ProtoMessage() {} func (*SetPGCredsRequest) ProtoMessage() {}
func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -781,7 +961,7 @@ func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetPGCredsRequest.ProtoReflect.Descriptor instead. // Deprecated: Use SetPGCredsRequest.ProtoReflect.Descriptor instead.
func (*SetPGCredsRequest) Descriptor() ([]byte, []int) { 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 { func (x *SetPGCredsRequest) GetId() string {
@@ -807,7 +987,7 @@ type SetPGCredsResponse struct {
func (x *SetPGCredsResponse) Reset() { func (x *SetPGCredsResponse) Reset() {
*x = SetPGCredsResponse{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -819,7 +999,7 @@ func (x *SetPGCredsResponse) String() string {
func (*SetPGCredsResponse) ProtoMessage() {} func (*SetPGCredsResponse) ProtoMessage() {}
func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -832,7 +1012,7 @@ func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetPGCredsResponse.ProtoReflect.Descriptor instead. // Deprecated: Use SetPGCredsResponse.ProtoReflect.Descriptor instead.
func (*SetPGCredsResponse) Descriptor() ([]byte, []int) { 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 var File_mcias_v1_account_proto protoreflect.FileDescriptor
@@ -867,7 +1047,15 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\x0fSetRolesRequest\x12\x0e\n" + "\x0fSetRolesRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
"\x05roles\x18\x02 \x03(\tR\x05roles\"\x12\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" + "\x11GetPGCredsRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"=\n" + "\x02id\x18\x01 \x01(\tR\x02id\"=\n" +
"\x12GetPGCredsResponse\x12'\n" + "\x12GetPGCredsResponse\x12'\n" +
@@ -875,7 +1063,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\x11SetPGCredsRequest\x12\x0e\n" + "\x11SetPGCredsRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12'\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12'\n" +
"\x05creds\x18\x02 \x01(\v2\x11.mcias.v1.PGCredsR\x05creds\"\x14\n" + "\x05creds\x18\x02 \x01(\v2\x11.mcias.v1.PGCredsR\x05creds\"\x14\n" +
"\x12SetPGCredsResponse2\xa4\x04\n" + "\x12SetPGCredsResponse2\xb3\x05\n" +
"\x0eAccountService\x12M\n" + "\x0eAccountService\x12M\n" +
"\fListAccounts\x12\x1d.mcias.v1.ListAccountsRequest\x1a\x1e.mcias.v1.ListAccountsResponse\x12P\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" + "\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" + "\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" + "\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" + "\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" + "\x11CredentialService\x12G\n" +
"\n" + "\n" +
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\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 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{ var file_mcias_v1_account_proto_goTypes = []any{
(*ListAccountsRequest)(nil), // 0: mcias.v1.ListAccountsRequest (*ListAccountsRequest)(nil), // 0: mcias.v1.ListAccountsRequest
(*ListAccountsResponse)(nil), // 1: mcias.v1.ListAccountsResponse (*ListAccountsResponse)(nil), // 1: mcias.v1.ListAccountsResponse
@@ -919,19 +1110,23 @@ var file_mcias_v1_account_proto_goTypes = []any{
(*GetRolesResponse)(nil), // 11: mcias.v1.GetRolesResponse (*GetRolesResponse)(nil), // 11: mcias.v1.GetRolesResponse
(*SetRolesRequest)(nil), // 12: mcias.v1.SetRolesRequest (*SetRolesRequest)(nil), // 12: mcias.v1.SetRolesRequest
(*SetRolesResponse)(nil), // 13: mcias.v1.SetRolesResponse (*SetRolesResponse)(nil), // 13: mcias.v1.SetRolesResponse
(*GetPGCredsRequest)(nil), // 14: mcias.v1.GetPGCredsRequest (*GrantRoleRequest)(nil), // 14: mcias.v1.GrantRoleRequest
(*GetPGCredsResponse)(nil), // 15: mcias.v1.GetPGCredsResponse (*GrantRoleResponse)(nil), // 15: mcias.v1.GrantRoleResponse
(*SetPGCredsRequest)(nil), // 16: mcias.v1.SetPGCredsRequest (*RevokeRoleRequest)(nil), // 16: mcias.v1.RevokeRoleRequest
(*SetPGCredsResponse)(nil), // 17: mcias.v1.SetPGCredsResponse (*RevokeRoleResponse)(nil), // 17: mcias.v1.RevokeRoleResponse
(*Account)(nil), // 18: mcias.v1.Account (*GetPGCredsRequest)(nil), // 18: mcias.v1.GetPGCredsRequest
(*PGCreds)(nil), // 19: mcias.v1.PGCreds (*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{ var file_mcias_v1_account_proto_depIdxs = []int32{
18, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account 22, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account
18, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account 22, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account
18, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account 22, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account
19, // 3: mcias.v1.GetPGCredsResponse.creds:type_name -> mcias.v1.PGCreds 23, // 3: mcias.v1.GetPGCredsResponse.creds:type_name -> mcias.v1.PGCreds
19, // 4: mcias.v1.SetPGCredsRequest.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 0, // 5: mcias.v1.AccountService.ListAccounts:input_type -> mcias.v1.ListAccountsRequest
2, // 6: mcias.v1.AccountService.CreateAccount:input_type -> mcias.v1.CreateAccountRequest 2, // 6: mcias.v1.AccountService.CreateAccount:input_type -> mcias.v1.CreateAccountRequest
4, // 7: mcias.v1.AccountService.GetAccount:input_type -> mcias.v1.GetAccountRequest 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 8, // 9: mcias.v1.AccountService.DeleteAccount:input_type -> mcias.v1.DeleteAccountRequest
10, // 10: mcias.v1.AccountService.GetRoles:input_type -> mcias.v1.GetRolesRequest 10, // 10: mcias.v1.AccountService.GetRoles:input_type -> mcias.v1.GetRolesRequest
12, // 11: mcias.v1.AccountService.SetRoles:input_type -> mcias.v1.SetRolesRequest 12, // 11: mcias.v1.AccountService.SetRoles:input_type -> mcias.v1.SetRolesRequest
14, // 12: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest 14, // 12: mcias.v1.AccountService.GrantRole:input_type -> mcias.v1.GrantRoleRequest
16, // 13: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest 16, // 13: mcias.v1.AccountService.RevokeRole:input_type -> mcias.v1.RevokeRoleRequest
1, // 14: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse 18, // 14: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest
3, // 15: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse 20, // 15: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest
5, // 16: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse 1, // 16: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse
7, // 17: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse 3, // 17: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse
9, // 18: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse 5, // 18: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse
11, // 19: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse 7, // 19: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse
13, // 20: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse 9, // 20: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse
15, // 21: mcias.v1.CredentialService.GetPGCreds:output_type -> mcias.v1.GetPGCredsResponse 11, // 21: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse
17, // 22: mcias.v1.CredentialService.SetPGCreds:output_type -> mcias.v1.SetPGCredsResponse 13, // 22: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse
14, // [14:23] is the sub-list for method output_type 15, // 23: mcias.v1.AccountService.GrantRole:output_type -> mcias.v1.GrantRoleResponse
5, // [5:14] is the sub-list for method input_type 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 type_name
5, // [5:5] is the sub-list for extension extendee 5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name 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(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_account_proto_rawDesc), len(file_mcias_v1_account_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_account_proto_rawDesc), len(file_mcias_v1_account_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 18, NumMessages: 22,
NumExtensions: 0, NumExtensions: 0,
NumServices: 2, NumServices: 2,
}, },

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.33.4
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1
@@ -29,6 +29,8 @@ const (
AccountService_DeleteAccount_FullMethodName = "/mcias.v1.AccountService/DeleteAccount" AccountService_DeleteAccount_FullMethodName = "/mcias.v1.AccountService/DeleteAccount"
AccountService_GetRoles_FullMethodName = "/mcias.v1.AccountService/GetRoles" AccountService_GetRoles_FullMethodName = "/mcias.v1.AccountService/GetRoles"
AccountService_SetRoles_FullMethodName = "/mcias.v1.AccountService/SetRoles" 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. // 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) DeleteAccount(ctx context.Context, in *DeleteAccountRequest, opts ...grpc.CallOption) (*DeleteAccountResponse, error)
GetRoles(ctx context.Context, in *GetRolesRequest, opts ...grpc.CallOption) (*GetRolesResponse, error) GetRoles(ctx context.Context, in *GetRolesRequest, opts ...grpc.CallOption) (*GetRolesResponse, error)
SetRoles(ctx context.Context, in *SetRolesRequest, opts ...grpc.CallOption) (*SetRolesResponse, 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 { type accountServiceClient struct {
@@ -124,6 +128,26 @@ func (c *accountServiceClient) SetRoles(ctx context.Context, in *SetRolesRequest
return out, nil 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. // AccountServiceServer is the server API for AccountService service.
// All implementations must embed UnimplementedAccountServiceServer // All implementations must embed UnimplementedAccountServiceServer
// for forward compatibility. // for forward compatibility.
@@ -137,6 +161,8 @@ type AccountServiceServer interface {
DeleteAccount(context.Context, *DeleteAccountRequest) (*DeleteAccountResponse, error) DeleteAccount(context.Context, *DeleteAccountRequest) (*DeleteAccountResponse, error)
GetRoles(context.Context, *GetRolesRequest) (*GetRolesResponse, error) GetRoles(context.Context, *GetRolesRequest) (*GetRolesResponse, error)
SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error)
GrantRole(context.Context, *GrantRoleRequest) (*GrantRoleResponse, error)
RevokeRole(context.Context, *RevokeRoleRequest) (*RevokeRoleResponse, error)
mustEmbedUnimplementedAccountServiceServer() mustEmbedUnimplementedAccountServiceServer()
} }
@@ -168,6 +194,12 @@ func (UnimplementedAccountServiceServer) GetRoles(context.Context, *GetRolesRequ
func (UnimplementedAccountServiceServer) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) { func (UnimplementedAccountServiceServer) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetRoles not implemented") 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) mustEmbedUnimplementedAccountServiceServer() {}
func (UnimplementedAccountServiceServer) testEmbeddedByValue() {} func (UnimplementedAccountServiceServer) testEmbeddedByValue() {}
@@ -315,6 +347,42 @@ func _AccountService_SetRoles_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler) 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. // AccountService_ServiceDesc is the grpc.ServiceDesc for AccountService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -350,6 +418,14 @@ var AccountService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetRoles", MethodName: "SetRoles",
Handler: _AccountService_SetRoles_Handler, Handler: _AccountService_SetRoles_Handler,
}, },
{
MethodName: "GrantRole",
Handler: _AccountService_GrantRole_Handler,
},
{
MethodName: "RevokeRole",
Handler: _AccountService_RevokeRole_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "mcias/v1/account.proto", Metadata: "mcias/v1/account.proto",

View File

@@ -227,3 +227,73 @@ func (a *accountServiceServer) SetRoles(ctx context.Context, req *mciasv1.SetRol
fmt.Sprintf(`{"roles":%v}`, req.Roles)) fmt.Sprintf(`{"roles":%v}`, req.Roles))
return &mciasv1.SetRolesResponse{}, nil 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
}

View File

@@ -130,6 +130,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount))) 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("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles))) 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("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds))) mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
@@ -666,6 +668,10 @@ type setRolesRequest struct {
Roles []string `json:"roles"` Roles []string `json:"roles"`
} }
type grantRoleRequest struct {
Role string `json:"role"`
}
func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r) acct, ok := s.loadAccount(w, r)
if !ok { if !ok {
@@ -710,6 +716,68 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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 ---- // ---- TOTP endpoints ----
type totpEnrollResponse struct { type totpEnrollResponse struct {

View File

@@ -995,6 +995,76 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $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: /v1/accounts/{id}/pgcreds:
parameters: parameters:
- name: id - name: id

View File

@@ -78,6 +78,24 @@ message SetRolesRequest {
// SetRolesResponse confirms the update. // SetRolesResponse confirms the update.
message SetRolesResponse {} 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. // AccountService manages accounts and roles. All RPCs require admin role.
service AccountService { service AccountService {
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse); rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
@@ -87,6 +105,8 @@ service AccountService {
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse); rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse);
rpc GetRoles(GetRolesRequest) returns (GetRolesResponse); rpc GetRoles(GetRolesRequest) returns (GetRolesResponse);
rpc SetRoles(SetRolesRequest) returns (SetRolesResponse); rpc SetRoles(SetRolesRequest) returns (SetRolesResponse);
rpc GrantRole(GrantRoleRequest) returns (GrantRoleResponse);
rpc RevokeRole(RevokeRoleRequest) returns (RevokeRoleResponse);
} }
// --- PG credentials --- // --- PG credentials ---