Files
mcias/internal/grpcserver/accountservice.go
Kyle Isom 59d51a1d38 Implement Phase 7: gRPC dual-stack interface
- proto/mcias/v1/: AdminService, AuthService, TokenService,
  AccountService, CredentialService; generated Go stubs in gen/
- internal/grpcserver: full handler implementations sharing all
  business logic (auth, token, db, crypto) with REST server;
  interceptor chain: logging -> auth (JWT alg-first + revocation) ->
  rate-limit (token bucket, 10 req/s, burst 10, per-IP)
- internal/config: optional grpc_addr field in [server] section
- cmd/mciassrv: dual-stack startup; gRPC/TLS listener on grpc_addr
  when configured; graceful shutdown of both servers in 15s window
- cmd/mciasgrpcctl: companion gRPC CLI mirroring mciasctl commands
  (health, pubkey, account, role, token, pgcreds) using TLS with
  optional custom CA cert
- internal/grpcserver/grpcserver_test.go: 20 tests via bufconn covering
  public RPCs, auth interceptor (no token, invalid, revoked -> 401),
  non-admin -> 403, Login/Logout/RenewToken/ValidateToken flows,
  AccountService CRUD, SetPGCreds/GetPGCreds AES-GCM round-trip,
  credential fields absent from all responses
Security:
  JWT validation path identical to REST: alg header checked before
  signature, alg:none rejected, revocation table checked after sig.
  Authorization metadata value never logged by any interceptor.
  Credential fields (PasswordHash, TOTPSecret*, PGPassword) absent from
  all proto response messages — enforced by proto design and confirmed
  by test TestCredentialFieldsAbsentFromAccountResponse.
  Login dummy-Argon2 timing guard preserves timing uniformity for
  unknown users (same as REST handleLogin).
  TLS required at listener level; cmd/mciassrv uses
  credentials.NewServerTLSFromFile; no h2c offered.
137 tests pass, zero race conditions (go test -race ./...)
2026-03-11 14:38:47 -07:00

223 lines
7.6 KiB
Go

// accountServiceServer implements mciasv1.AccountServiceServer.
// All RPCs require admin role.
package grpcserver
import (
"context"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
)
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
}
if req.Username == "" {
return nil, status.Error(codes.InvalidArgument, "username is required")
}
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")
}
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 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 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 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 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 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
}