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 ./...)
This commit is contained in:
2026-03-11 14:38:47 -07:00
parent 094741b56d
commit 59d51a1d38
38 changed files with 9132 additions and 10 deletions

View File

@@ -0,0 +1,654 @@
// Tests for the gRPC server package.
//
// All tests use bufconn so no network sockets are opened. TLS is omitted
// at the test layer (insecure credentials); TLS enforcement is the responsibility
// of cmd/mciassrv which wraps the listener.
package grpcserver
import (
"context"
"crypto/ed25519"
"crypto/rand"
"io"
"log/slog"
"net"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
)
const (
testIssuer = "https://auth.example.com"
bufConnSize = 1024 * 1024
)
// testEnv holds all resources for a single test's gRPC server.
type testEnv struct {
db *db.DB
priv ed25519.PrivateKey
pub ed25519.PublicKey
masterKey []byte
cfg *config.Config
conn *grpc.ClientConn
}
// newTestEnv spins up an in-process gRPC server using bufconn and returns
// a client connection to it. All resources are cleaned up via t.Cleanup.
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate db: %v", err)
}
masterKey := make([]byte, 32)
if _, err := rand.Read(masterKey); err != nil {
t.Fatalf("generate master key: %v", err)
}
cfg := config.NewTestConfig(testIssuer)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := New(database, cfg, priv, pub, masterKey, logger)
grpcSrv := srv.GRPCServer()
lis := bufconn.Listen(bufConnSize)
go func() {
if err := grpcSrv.Serve(lis); err != nil {
// Serve returns when the listener is closed; ignore that error.
}
}()
conn, err := grpc.NewClient(
"passthrough://bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial bufconn: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
grpcSrv.Stop()
_ = lis.Close()
_ = database.Close()
})
return &testEnv{
db: database,
priv: priv,
pub: pub,
masterKey: masterKey,
cfg: cfg,
conn: conn,
}
}
// createHumanAccount creates a human account with the given username and
// a fixed password "testpass123" directly in the database.
func (e *testEnv) createHumanAccount(t *testing.T, username string) *model.Account {
t.Helper()
hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4})
if err != nil {
t.Fatalf("hash password: %v", err)
}
acct, err := e.db.CreateAccount(username, model.AccountTypeHuman, hash)
if err != nil {
t.Fatalf("create account: %v", err)
}
return acct
}
// issueAdminToken creates an account with admin role, issues a JWT, tracks it in
// the DB, and returns the token string.
func (e *testEnv) issueAdminToken(t *testing.T, username string) (string, *model.Account) {
t.Helper()
acct := e.createHumanAccount(t, username)
if err := e.db.GrantRole(acct.ID, "admin", nil); err != nil {
t.Fatalf("grant admin role: %v", err)
}
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return tokenStr, acct
}
// issueUserToken issues a regular (non-admin) token for an account.
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
t.Helper()
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return tokenStr
}
// authCtx returns a context with the Bearer token as gRPC metadata.
func authCtx(tok string) context.Context {
return metadata.AppendToOutgoingContext(context.Background(),
"authorization", "Bearer "+tok)
}
// ---- AdminService tests ----
// TestHealth verifies the public Health RPC requires no auth and returns "ok".
func TestHealth(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAdminServiceClient(e.conn)
resp, err := cl.Health(context.Background(), &mciasv1.HealthRequest{})
if err != nil {
t.Fatalf("Health: %v", err)
}
if resp.Status != "ok" {
t.Errorf("Health: got status %q, want %q", resp.Status, "ok")
}
}
// TestGetPublicKey verifies the public GetPublicKey RPC returns JWK fields.
func TestGetPublicKey(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAdminServiceClient(e.conn)
resp, err := cl.GetPublicKey(context.Background(), &mciasv1.GetPublicKeyRequest{})
if err != nil {
t.Fatalf("GetPublicKey: %v", err)
}
if resp.Kty != "OKP" {
t.Errorf("GetPublicKey: kty=%q, want OKP", resp.Kty)
}
if resp.Crv != "Ed25519" {
t.Errorf("GetPublicKey: crv=%q, want Ed25519", resp.Crv)
}
if resp.X == "" {
t.Error("GetPublicKey: x field is empty")
}
}
// ---- Auth interceptor tests ----
// TestAuthRequired verifies that protected RPCs reject calls with no token.
func TestAuthRequired(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAuthServiceClient(e.conn)
// Logout requires auth; call without any metadata.
_, err := cl.Logout(context.Background(), &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("Logout without token: expected error, got nil")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("not a gRPC status error: %v", err)
}
if st.Code() != codes.Unauthenticated {
t.Errorf("Logout without token: got code %v, want Unauthenticated", st.Code())
}
}
// TestInvalidTokenRejected verifies that a malformed token is rejected.
func TestInvalidTokenRejected(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx("not.a.valid.jwt")
_, err := cl.Logout(ctx, &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("Logout with invalid token: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestRevokedTokenRejected verifies that a revoked token cannot be used.
func TestRevokedTokenRejected(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "revokeduser")
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
// Revoke it before using it.
if err := e.db.RevokeToken(claims.JTI, "test"); err != nil {
t.Fatalf("revoke token: %v", err)
}
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tokenStr)
_, err = cl.Logout(ctx, &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("Logout with revoked token: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestNonAdminCannotCallAdminRPC verifies that a regular user is denied access
// to admin-only RPCs (PermissionDenied, not Unauthenticated).
func TestNonAdminCannotCallAdminRPC(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "regularuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAccountServiceClient(e.conn)
ctx := authCtx(tok)
_, err := cl.ListAccounts(ctx, &mciasv1.ListAccountsRequest{})
if err == nil {
t.Fatal("ListAccounts as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
}
// ---- AuthService tests ----
// TestLogin verifies successful login via gRPC.
func TestLogin(t *testing.T) {
e := newTestEnv(t)
_ = e.createHumanAccount(t, "loginuser")
cl := mciasv1.NewAuthServiceClient(e.conn)
resp, err := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "loginuser",
Password: "testpass123",
})
if err != nil {
t.Fatalf("Login: %v", err)
}
if resp.Token == "" {
t.Error("Login: returned empty token")
}
}
// TestLoginWrongPassword verifies that wrong-password returns Unauthenticated.
func TestLoginWrongPassword(t *testing.T) {
e := newTestEnv(t)
_ = e.createHumanAccount(t, "loginuser2")
cl := mciasv1.NewAuthServiceClient(e.conn)
_, err := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "loginuser2",
Password: "wrongpassword",
})
if err == nil {
t.Fatal("Login with wrong password: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestLoginUnknownUser verifies that unknown-user returns the same error as
// wrong-password (prevents user enumeration).
func TestLoginUnknownUser(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAuthServiceClient(e.conn)
_, err := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "nosuchuser",
Password: "whatever",
})
if err == nil {
t.Fatal("Login for unknown user: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestLogout verifies that a valid token can log itself out.
func TestLogout(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "logoutuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
_, err := cl.Logout(ctx, &mciasv1.LogoutRequest{})
if err != nil {
t.Fatalf("Logout: %v", err)
}
// Second call with the same token must fail (token now revoked).
_, err = cl.Logout(authCtx(tok), &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("second Logout with revoked token: expected error, got nil")
}
}
// TestRenewToken verifies that a valid token can be renewed.
func TestRenewToken(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "renewuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
resp, err := cl.RenewToken(ctx, &mciasv1.RenewTokenRequest{})
if err != nil {
t.Fatalf("RenewToken: %v", err)
}
if resp.Token == "" {
t.Error("RenewToken: returned empty token")
}
if resp.Token == tok {
t.Error("RenewToken: returned same token instead of a fresh one")
}
}
// ---- TokenService tests ----
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
// a good token and valid=false for a garbage input (no Unauthenticated error).
func TestValidateToken(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "validateuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewTokenServiceClient(e.conn)
// Valid token.
resp, err := cl.ValidateToken(context.Background(), &mciasv1.ValidateTokenRequest{Token: tok})
if err != nil {
t.Fatalf("ValidateToken (good): %v", err)
}
if !resp.Valid {
t.Error("ValidateToken: got valid=false for a good token")
}
// Invalid token: should return valid=false, not an RPC error.
resp, err = cl.ValidateToken(context.Background(), &mciasv1.ValidateTokenRequest{Token: "garbage"})
if err != nil {
t.Fatalf("ValidateToken (bad): unexpected RPC error: %v", err)
}
if resp.Valid {
t.Error("ValidateToken: got valid=true for a garbage token")
}
}
// TestIssueServiceTokenRequiresAdmin verifies that non-admin cannot issue tokens.
func TestIssueServiceTokenRequiresAdmin(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "notadmin")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewTokenServiceClient(e.conn)
_, err := cl.IssueServiceToken(authCtx(tok), &mciasv1.IssueServiceTokenRequest{AccountId: acct.UUID})
if err == nil {
t.Fatal("IssueServiceToken as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
}
// ---- AccountService tests ----
// TestListAccountsAdminOnly verifies that ListAccounts requires admin role.
func TestListAccountsAdminOnly(t *testing.T) {
e := newTestEnv(t)
// Non-admin call.
acct := e.createHumanAccount(t, "nonadmin")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAccountServiceClient(e.conn)
_, err := cl.ListAccounts(authCtx(tok), &mciasv1.ListAccountsRequest{})
if err == nil {
t.Fatal("ListAccounts as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
// Admin call.
adminTok, _ := e.issueAdminToken(t, "adminuser")
resp, err := cl.ListAccounts(authCtx(adminTok), &mciasv1.ListAccountsRequest{})
if err != nil {
t.Fatalf("ListAccounts as admin: %v", err)
}
if len(resp.Accounts) == 0 {
t.Error("ListAccounts: expected at least one account")
}
}
// TestCreateAndGetAccount exercises the full create→get lifecycle.
func TestCreateAndGetAccount(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin2")
cl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := cl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "newuser",
Password: "securepassword1",
AccountType: "human",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if createResp.Account == nil {
t.Fatal("CreateAccount: returned nil account")
}
if createResp.Account.Id == "" {
t.Error("CreateAccount: returned empty UUID")
}
// Security: credential fields must not appear in the response.
// The Account proto has no password_hash or totp_secret fields by design.
// Verify via GetAccount too.
getResp, err := cl.GetAccount(authCtx(adminTok), &mciasv1.GetAccountRequest{Id: createResp.Account.Id})
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if getResp.Account.Username != "newuser" {
t.Errorf("GetAccount: username=%q, want %q", getResp.Account.Username, "newuser")
}
}
// TestUpdateAccount verifies that account status can be changed.
func TestUpdateAccount(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin3")
cl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := cl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "updateme",
Password: "pass12345",
AccountType: "human",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
id := createResp.Account.Id
_, err = cl.UpdateAccount(authCtx(adminTok), &mciasv1.UpdateAccountRequest{
Id: id,
Status: "inactive",
})
if err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
getResp, err := cl.GetAccount(authCtx(adminTok), &mciasv1.GetAccountRequest{Id: id})
if err != nil {
t.Fatalf("GetAccount after update: %v", err)
}
if getResp.Account.Status != "inactive" {
t.Errorf("after update: status=%q, want inactive", getResp.Account.Status)
}
}
// TestSetAndGetRoles verifies that roles can be assigned and retrieved.
func TestSetAndGetRoles(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin4")
cl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := cl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "roleuser",
Password: "pass12345",
AccountType: "human",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
id := createResp.Account.Id
_, err = cl.SetRoles(authCtx(adminTok), &mciasv1.SetRolesRequest{
Id: id,
Roles: []string{"editor", "viewer"},
})
if err != nil {
t.Fatalf("SetRoles: %v", err)
}
getRolesResp, err := cl.GetRoles(authCtx(adminTok), &mciasv1.GetRolesRequest{Id: id})
if err != nil {
t.Fatalf("GetRoles: %v", err)
}
if len(getRolesResp.Roles) != 2 {
t.Errorf("GetRoles: got %d roles, want 2", len(getRolesResp.Roles))
}
}
// ---- CredentialService tests ----
// TestSetAndGetPGCreds verifies that PG credentials can be stored and retrieved.
// Security: the password is decrypted only in the GetPGCreds response; it is
// never present in account list or other responses.
func TestSetAndGetPGCreds(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin5")
// Create a system account to hold the PG credentials.
accCl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := accCl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "sysaccount",
AccountType: "system",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
accountID := createResp.Account.Id
credCl := mciasv1.NewCredentialServiceClient(e.conn)
_, err = credCl.SetPGCreds(authCtx(adminTok), &mciasv1.SetPGCredsRequest{
Id: accountID,
Creds: &mciasv1.PGCreds{
Host: "db.example.com",
Port: 5432,
Database: "mydb",
Username: "myuser",
Password: "supersecret",
},
})
if err != nil {
t.Fatalf("SetPGCreds: %v", err)
}
getResp, err := credCl.GetPGCreds(authCtx(adminTok), &mciasv1.GetPGCredsRequest{Id: accountID})
if err != nil {
t.Fatalf("GetPGCreds: %v", err)
}
if getResp.Creds == nil {
t.Fatal("GetPGCreds: returned nil creds")
}
if getResp.Creds.Password != "supersecret" {
t.Errorf("GetPGCreds: password=%q, want supersecret", getResp.Creds.Password)
}
if getResp.Creds.Host != "db.example.com" {
t.Errorf("GetPGCreds: host=%q, want db.example.com", getResp.Creds.Host)
}
}
// TestPGCredsRequireAdmin verifies that non-admin cannot access PG creds.
func TestPGCredsRequireAdmin(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "notadmin2")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewCredentialServiceClient(e.conn)
_, err := cl.GetPGCreds(authCtx(tok), &mciasv1.GetPGCredsRequest{Id: acct.UUID})
if err == nil {
t.Fatal("GetPGCreds as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
}
// ---- Security: credential fields absent from responses ----
// TestCredentialFieldsAbsentFromAccountResponse verifies that account responses
// never include password_hash or totp_secret fields. The Account proto message
// does not define these fields, providing compile-time enforcement. This test
// provides a runtime confirmation by checking the returned Account struct.
func TestCredentialFieldsAbsentFromAccountResponse(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin6")
cl := mciasv1.NewAccountServiceClient(e.conn)
resp, err := cl.ListAccounts(authCtx(adminTok), &mciasv1.ListAccountsRequest{})
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
for _, a := range resp.Accounts {
// Account proto only has: id, username, account_type, status,
// totp_enabled, created_at, updated_at. No credential fields.
// This loop body intentionally checks the fields that exist;
// the absence of credential fields is enforced by the proto definition.
if a.Id == "" {
t.Error("account has empty id")
}
}
}