- 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.
300 lines
10 KiB
Go
300 lines
10 KiB
Go
// accountServiceServer implements mciasv1.AccountServiceServer.
|
|
// All RPCs require admin role.
|
|
package grpcserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
|
)
|
|
|
|
type accountServiceServer struct {
|
|
mciasv1.UnimplementedAccountServiceServer
|
|
s *Server
|
|
}
|
|
|
|
// accountToProto converts an internal Account to the proto message.
|
|
// Credential fields (PasswordHash, TOTPSecret*) are never included.
|
|
func accountToProto(a *model.Account) *mciasv1.Account {
|
|
acc := &mciasv1.Account{
|
|
Id: a.UUID,
|
|
Username: a.Username,
|
|
AccountType: string(a.AccountType),
|
|
Status: string(a.Status),
|
|
TotpEnabled: a.TOTPRequired,
|
|
CreatedAt: timestamppb.New(a.CreatedAt),
|
|
UpdatedAt: timestamppb.New(a.UpdatedAt),
|
|
}
|
|
return acc
|
|
}
|
|
|
|
// ListAccounts returns all accounts. Admin only.
|
|
func (a *accountServiceServer) ListAccounts(ctx context.Context, _ *mciasv1.ListAccountsRequest) (*mciasv1.ListAccountsResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
accounts, err := a.s.db.ListAccounts()
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
resp := make([]*mciasv1.Account, len(accounts))
|
|
for i, acct := range accounts {
|
|
resp[i] = accountToProto(acct)
|
|
}
|
|
return &mciasv1.ListAccountsResponse{Accounts: resp}, nil
|
|
}
|
|
|
|
// CreateAccount creates a new account. Admin only.
|
|
func (a *accountServiceServer) CreateAccount(ctx context.Context, req *mciasv1.CreateAccountRequest) (*mciasv1.CreateAccountResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
// Security (F-12): validate username length and character set.
|
|
if err := validate.Username(req.Username); err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
}
|
|
accountType := model.AccountType(req.AccountType)
|
|
if accountType != model.AccountTypeHuman && accountType != model.AccountTypeSystem {
|
|
return nil, status.Error(codes.InvalidArgument, "account_type must be 'human' or 'system'")
|
|
}
|
|
|
|
var passwordHash string
|
|
if accountType == model.AccountTypeHuman {
|
|
if req.Password == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "password is required for human accounts")
|
|
}
|
|
// Security (F-13): enforce minimum length before hashing.
|
|
if err := validate.Password(req.Password); err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
}
|
|
var err error
|
|
passwordHash, err = auth.HashPassword(req.Password, auth.ArgonParams{
|
|
Time: a.s.cfg.Argon2.Time,
|
|
Memory: a.s.cfg.Argon2.Memory,
|
|
Threads: a.s.cfg.Argon2.Threads,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
}
|
|
|
|
acct, err := a.s.db.CreateAccount(req.Username, accountType, passwordHash)
|
|
if err != nil {
|
|
return nil, status.Error(codes.AlreadyExists, "username already exists")
|
|
}
|
|
|
|
a.s.db.WriteAuditEvent(model.EventAccountCreated, nil, &acct.ID, peerIP(ctx), //nolint:errcheck
|
|
fmt.Sprintf(`{"username":%q}`, acct.Username))
|
|
return &mciasv1.CreateAccountResponse{Account: accountToProto(acct)}, nil
|
|
}
|
|
|
|
// GetAccount retrieves a single account by UUID. Admin only.
|
|
func (a *accountServiceServer) GetAccount(ctx context.Context, req *mciasv1.GetAccountRequest) (*mciasv1.GetAccountResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "id 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")
|
|
}
|
|
return &mciasv1.GetAccountResponse{Account: accountToProto(acct)}, nil
|
|
}
|
|
|
|
// UpdateAccount updates mutable fields. Admin only.
|
|
func (a *accountServiceServer) UpdateAccount(ctx context.Context, req *mciasv1.UpdateAccountRequest) (*mciasv1.UpdateAccountResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "id 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")
|
|
}
|
|
|
|
if req.Status != "" {
|
|
newStatus := model.AccountStatus(req.Status)
|
|
if newStatus != model.AccountStatusActive && newStatus != model.AccountStatusInactive {
|
|
return nil, status.Error(codes.InvalidArgument, "status must be 'active' or 'inactive'")
|
|
}
|
|
if err := a.s.db.UpdateAccountStatus(acct.ID, newStatus); err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
}
|
|
|
|
a.s.db.WriteAuditEvent(model.EventAccountUpdated, nil, &acct.ID, peerIP(ctx), "") //nolint:errcheck
|
|
return &mciasv1.UpdateAccountResponse{}, nil
|
|
}
|
|
|
|
// DeleteAccount soft-deletes an account and revokes its tokens. Admin only.
|
|
func (a *accountServiceServer) DeleteAccount(ctx context.Context, req *mciasv1.DeleteAccountRequest) (*mciasv1.DeleteAccountResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "id 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")
|
|
}
|
|
if err := a.s.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
if err := a.s.db.RevokeAllUserTokens(acct.ID, "account deleted"); err != nil {
|
|
a.s.logger.Error("revoke tokens on delete", "error", err, "account_id", acct.ID)
|
|
}
|
|
a.s.db.WriteAuditEvent(model.EventAccountDeleted, nil, &acct.ID, peerIP(ctx), "") //nolint:errcheck
|
|
return &mciasv1.DeleteAccountResponse{}, nil
|
|
}
|
|
|
|
// GetRoles returns the roles for an account. Admin only.
|
|
func (a *accountServiceServer) GetRoles(ctx context.Context, req *mciasv1.GetRolesRequest) (*mciasv1.GetRolesResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "id 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")
|
|
}
|
|
roles, err := a.s.db.GetRoles(acct.ID)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
if roles == nil {
|
|
roles = []string{}
|
|
}
|
|
return &mciasv1.GetRolesResponse{Roles: roles}, nil
|
|
}
|
|
|
|
// SetRoles replaces the role set for an account. Admin only.
|
|
func (a *accountServiceServer) SetRoles(ctx context.Context, req *mciasv1.SetRolesRequest) (*mciasv1.SetRolesResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "id 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.SetRoles(acct.ID, req.Roles, grantedBy); err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
a.s.db.WriteAuditEvent(model.EventRoleGranted, grantedBy, &acct.ID, peerIP(ctx), //nolint:errcheck
|
|
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
|
|
}
|