// 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 }