Migrate db, auth to mcdsl; remove mcias client dependency

- db.Open: delegate to mcdsl/db.Open
- db.Migrate: convert to mcdsl/db.Migration format, delegate
- auth: type aliases for TokenInfo/Authenticator/Config from mcdsl,
  re-export error sentinels, Logout helper
- cmd/server: construct auth.Authenticator from Config (not mcias.Client)
- server/routes.go logout: use auth.Logout(authenticator, token)
- grpcserver/auth.go: same logout pattern, fix Login return type
  (time.Time not string)
- webserver: replace mcias.Client with mcdsl/auth for service token
  validation; resolveUser degrades to raw UUID (TODO: restore when
  mcias client library is properly tagged)
- Dockerfiles: bump to golang:1.25-alpine, remove gcc/musl-dev,
  add VERSION build arg
- Deploy: add docker-compose-rift.yml with localhost-only port mapping
- Remove git.wntrmute.dev/kyle/mcias/clients/go dependency entirely
- All tests pass, net -185 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 18:42:43 -07:00
parent 5c5d7e184e
commit dd698ff6d8
15 changed files with 157 additions and 342 deletions

View File

@@ -1,137 +1,49 @@
// Package auth provides MCIAS authentication integration with token caching.
// Package auth provides MCIAS authentication integration, delegating to
// the mcdsl/auth package for token validation with caching.
package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"sync"
"time"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
)
// TokenInfo is an alias for the mcdsl auth.TokenInfo type.
type TokenInfo = mcdslauth.TokenInfo
// Authenticator is an alias for the mcdsl auth.Authenticator type.
type Authenticator = mcdslauth.Authenticator
// Config is an alias for the mcdsl auth.Config type.
type Config = mcdslauth.Config
// Errors re-exported from mcdsl/auth for compatibility.
var (
ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInvalidToken = errors.New("auth: invalid token")
ErrInvalidCredentials = mcdslauth.ErrInvalidCredentials
ErrInvalidToken = mcdslauth.ErrInvalidToken
ErrForbidden = mcdslauth.ErrForbidden
ErrUnavailable = mcdslauth.ErrUnavailable
)
const tokenCacheTTL = 30 * time.Second
// TokenInfo holds validated token information.
type TokenInfo struct {
Username string
Roles []string
IsAdmin bool
// NewAuthenticator creates a new Authenticator backed by mcdsl/auth.
// This is a convenience wrapper matching the old constructor signature.
func NewAuthenticator(cfg mcdslauth.Config, logger *slog.Logger) (*Authenticator, error) {
return mcdslauth.New(cfg, logger)
}
// cachedClaims holds a cached token validation result.
type cachedClaims struct {
info *TokenInfo
expiresAt time.Time
// Logout revokes a token on the MCIAS server.
func Logout(authenticator *Authenticator, token string) error {
return authenticator.Logout(token)
}
// Authenticator provides MCIAS-backed authentication.
type Authenticator struct {
client *mcias.Client
logger *slog.Logger
cache map[string]*cachedClaims
mu sync.RWMutex
}
// NewAuthenticator creates a new authenticator with the given MCIAS client.
func NewAuthenticator(client *mcias.Client, logger *slog.Logger) *Authenticator {
return &Authenticator{
client: client,
logger: logger,
cache: make(map[string]*cachedClaims),
}
}
// Login authenticates a user via MCIAS and returns the token.
func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt string, err error) {
a.logger.Debug("login attempt", "username", username)
tok, exp, err := a.client.Login(username, password, totpCode)
if err != nil {
var authErr *mcias.MciasAuthError
if errors.As(err, &authErr) {
a.logger.Debug("login failed: invalid credentials", "username", username)
return "", "", ErrInvalidCredentials
}
a.logger.Debug("login failed", "username", username, "error", err)
return "", "", err
}
a.logger.Debug("login succeeded", "username", username)
return tok, exp, nil
}
// ValidateToken validates a bearer token, using a short-lived cache.
func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) {
key := tokenHash(token)
// Check cache.
a.mu.RLock()
cached, ok := a.cache[key]
a.mu.RUnlock()
if ok && time.Now().Before(cached.expiresAt) {
a.logger.Debug("token validated from cache")
return cached.info, nil
}
a.logger.Debug("validating token with MCIAS")
// Validate with MCIAS.
claims, err := a.client.ValidateToken(token)
if err != nil {
a.logger.Debug("token validation failed", "error", err)
return nil, err
}
if !claims.Valid {
a.logger.Debug("token invalid per MCIAS")
return nil, ErrInvalidToken
}
info := &TokenInfo{
Username: claims.Sub,
Roles: claims.Roles,
IsAdmin: hasAdminRole(claims.Roles),
}
// Cache the result.
a.mu.Lock()
a.cache[key] = &cachedClaims{
info: info,
expiresAt: time.Now().Add(tokenCacheTTL),
}
a.mu.Unlock()
a.logger.Debug("token validated and cached", "username", info.Username, "is_admin", info.IsAdmin)
return info, nil
}
// Logout invalidates a token via MCIAS. The client must have the token set.
func (a *Authenticator) Logout(client *mcias.Client) error {
return client.Logout()
}
// ClearCache removes all cached token validations.
func (a *Authenticator) ClearCache() {
a.logger.Debug("clearing token cache")
a.mu.Lock()
a.cache = make(map[string]*cachedClaims)
a.mu.Unlock()
}
func tokenHash(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func hasAdminRole(roles []string) bool {
for _, r := range roles {
if r == "admin" {
return true
}
}
return false
// ContextWithTokenInfo stores TokenInfo in a context.
var ContextWithTokenInfo = mcdslauth.ContextWithTokenInfo
// TokenInfoFromContext extracts TokenInfo from a context.
var TokenInfoFromContext = mcdslauth.TokenInfoFromContext
// IsInvalidCredentials checks if an error is ErrInvalidCredentials.
func IsInvalidCredentials(err error) bool {
return errors.Is(err, ErrInvalidCredentials)
}

View File

@@ -1,53 +1,21 @@
package auth
import (
"log/slog"
"testing"
)
func TestTokenHash(t *testing.T) {
h1 := tokenHash("token-abc")
h2 := tokenHash("token-abc")
h3 := tokenHash("token-def")
if h1 != h2 {
t.Error("same input should produce same hash")
func TestErrorsExported(t *testing.T) {
// Verify the error sentinels are accessible and non-nil.
if ErrInvalidCredentials == nil {
t.Error("ErrInvalidCredentials is nil")
}
if h1 == h3 {
t.Error("different inputs should produce different hashes")
if ErrInvalidToken == nil {
t.Error("ErrInvalidToken is nil")
}
if len(h1) != 64 { // SHA-256 hex
t.Errorf("hash length: got %d, want 64", len(h1))
}
}
func TestHasAdminRole(t *testing.T) {
if !hasAdminRole([]string{"user", "admin"}) {
t.Error("should detect admin role")
}
if hasAdminRole([]string{"user", "operator"}) {
t.Error("should not detect admin role when absent")
}
if hasAdminRole(nil) {
t.Error("nil roles should not be admin")
}
}
func TestNewAuthenticator(t *testing.T) {
a := NewAuthenticator(nil, slog.Default())
if a == nil {
t.Fatal("NewAuthenticator returned nil")
}
if a.cache == nil {
t.Error("cache should be initialized")
}
}
func TestClearCache(t *testing.T) {
a := NewAuthenticator(nil, slog.Default())
a.cache["test"] = &cachedClaims{info: &TokenInfo{Username: "test"}}
a.ClearCache()
if len(a.cache) != 0 {
t.Error("cache should be empty after clear")
if ErrForbidden == nil {
t.Error("ErrForbidden is nil")
}
if ErrUnavailable == nil {
t.Error("ErrUnavailable is nil")
}
}

View File

@@ -3,41 +3,13 @@ package db
import (
"database/sql"
"fmt"
"os"
_ "modernc.org/sqlite"
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
)
// Open opens or creates a SQLite database at the given path with secure
// file permissions (0600) and WAL mode enabled.
// Open opens or creates a SQLite database at the given path with the
// standard Metacircular pragmas (WAL, FK, busy timeout) and 0600
// permissions.
func Open(path string) (*sql.DB, error) {
// Ensure the file has restrictive permissions if it doesn't exist yet.
if _, err := os.Stat(path); os.IsNotExist(err) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("db: create file: %w", err)
}
_ = f.Close()
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("db: open: %w", err)
}
// Enable WAL mode and foreign keys.
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
_ = db.Close()
return nil, fmt.Errorf("db: pragma %q: %w", p, err)
}
}
return db, nil
return mcdsldb.Open(path)
}

View File

@@ -2,14 +2,16 @@ package db
import (
"database/sql"
"fmt"
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
)
// migrations is an ordered list of SQL DDL statements. Each index is the
// migration version (1-based).
var migrations = []string{
// Version 1: initial schema
`CREATE TABLE IF NOT EXISTS seal_config (
// Migrations is the ordered list of metacrypt schema migrations.
var Migrations = []mcdsldb.Migration{
{
Version: 1,
Name: "initial schema",
SQL: `CREATE TABLE IF NOT EXISTS seal_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
encrypted_mek BLOB NOT NULL,
kdf_salt BLOB NOT NULL,
@@ -24,56 +26,22 @@ var migrations = []string{
value BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT (datetime('now'))
);`,
// Version 2: barrier key registry for per-engine DEKs
`CREATE TABLE IF NOT EXISTS barrier_keys (
},
{
Version: 2,
Name: "barrier key registry",
SQL: `CREATE TABLE IF NOT EXISTS barrier_keys (
key_id TEXT PRIMARY KEY,
version INTEGER NOT NULL DEFAULT 1,
encrypted_dek BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
rotated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);`,
},
}
// Migrate applies all pending migrations.
func Migrate(db *sql.DB) error {
// Ensure the migrations table exists (bootstrap).
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("db: create migrations table: %w", err)
}
var current int
row := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations")
if err := row.Scan(&current); err != nil {
return fmt.Errorf("db: get migration version: %w", err)
}
for i := current; i < len(migrations); i++ {
version := i + 1
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("db: begin migration %d: %w", version, err)
}
if _, err := tx.Exec(migrations[i]); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: migration %d: %w", version, err)
}
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: record migration %d: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: commit migration %d: %w", version, err)
}
}
return nil
func Migrate(database *sql.DB) error {
return mcdsldb.Migrate(database, Migrations)
}

View File

@@ -2,15 +2,13 @@ package grpcserver
import (
"context"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
)
type authServer struct {
@@ -19,13 +17,13 @@ type authServer struct {
}
func (as *authServer) Login(_ context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
token, expiresAtStr, err := as.s.auth.Login(req.Username, req.Password, req.TotpCode)
token, expiresAtTime, err := as.s.auth.Login(req.Username, req.Password, req.TotpCode)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
var expiresAt *timestamppb.Timestamp
if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil {
expiresAt = timestamppb.New(t)
if !expiresAtTime.IsZero() {
expiresAt = timestamppb.New(expiresAtTime)
}
as.s.logger.Info("audit: login", "username", req.Username)
return &pb.LoginResponse{Token: token, ExpiresAt: expiresAt}, nil
@@ -33,13 +31,7 @@ func (as *authServer) Login(_ context.Context, req *pb.LoginRequest) (*pb.LoginR
func (as *authServer) Logout(ctx context.Context, _ *pb.LogoutRequest) (*pb.LogoutResponse, error) {
token := extractToken(ctx)
client, err := mcias.New(as.s.cfg.MCIAS.ServerURL, mcias.Options{
CACertPath: as.s.cfg.MCIAS.CACert,
Token: token,
})
if err == nil {
_ = as.s.auth.Logout(client)
}
_ = auth.Logout(as.s.auth, token)
as.s.logger.Info("audit: logout", "username", callerUsername(ctx))
return &pb.LogoutResponse{}, nil
}

View File

@@ -74,7 +74,7 @@ func newTestGRPCServer(t *testing.T) (*GRPCServer, func()) {
sealMgr := seal.NewManager(database, b, nil, slog.Default())
policyEngine := policy.NewEngine(b)
reg := newTestRegistry()
authenticator := auth.NewAuthenticator(nil, slog.Default())
authenticator, _ := auth.NewAuthenticator(auth.Config{ServerURL: "http://localhost:0"}, slog.Default())
cfg := &config.Config{
Seal: config.SealConfig{
Argon2Time: 1,
@@ -159,7 +159,7 @@ func TestSealInterceptor_SkipsUnlistedMethod(t *testing.T) {
}
func TestAuthInterceptor_MissingToken(t *testing.T) {
authenticator := auth.NewAuthenticator(nil, slog.Default())
authenticator, _ := auth.NewAuthenticator(auth.Config{ServerURL: "http://localhost:0"}, slog.Default())
methods := map[string]bool{"/test.Service/Method": true}
interceptor := authInterceptor(authenticator, slog.Default(), methods)
@@ -173,7 +173,7 @@ func TestAuthInterceptor_MissingToken(t *testing.T) {
}
func TestAuthInterceptor_SkipsUnlistedMethod(t *testing.T) {
authenticator := auth.NewAuthenticator(nil, slog.Default())
authenticator, _ := auth.NewAuthenticator(auth.Config{ServerURL: "http://localhost:0"}, slog.Default())
methods := map[string]bool{"/test.Service/Other": true}
interceptor := authInterceptor(authenticator, slog.Default(), methods)

View File

@@ -9,7 +9,7 @@ import (
"github.com/go-chi/chi/v5"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
"git.wntrmute.dev/kyle/metacrypt/internal/audit"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
@@ -236,13 +236,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{
CACertPath: s.cfg.MCIAS.CACert,
Token: token,
})
if err == nil {
_ = s.auth.Logout(client)
}
_ = auth.Logout(s.auth, token)
// Clear cookie.
http.SetCookie(w, &http.Cookie{

View File

@@ -39,9 +39,8 @@ func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) {
sealMgr := seal.NewManager(database, b, nil, slog.Default())
_ = sealMgr.CheckInitialized()
// Auth requires MCIAS client which we can't create in tests easily,
// so we pass nil and avoid auth-dependent routes in these tests.
authenticator := auth.NewAuthenticator(nil, slog.Default())
// Auth not exercised in these tests — token info is injected directly.
authenticator, _ := auth.NewAuthenticator(auth.Config{ServerURL: "http://localhost:0"}, slog.Default())
policyEngine := policy.NewEngine(b)
engineRegistry := engine.NewRegistry(b, slog.Default())

View File

@@ -15,7 +15,7 @@ import (
"github.com/go-chi/chi/v5"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
webui "git.wntrmute.dev/kyle/metacrypt/web"
)
@@ -113,8 +113,7 @@ type cachedUsername struct {
type WebServer struct {
cfg *config.Config
vault vaultBackend
mcias *mcias.Client // optional; nil when no service_token is configured
logger *slog.Logger
logger *slog.Logger
httpSrv *http.Server
staticFS fs.FS
csrf *csrfProtect
@@ -136,22 +135,9 @@ func (ws *WebServer) resolveUser(id string) string {
return entry.username
}
}
if ws.mcias == nil {
ws.logger.Warn("webserver: no MCIAS client available, cannot resolve user ID", "id", id)
return id
}
ws.logger.Info("webserver: looking up user ID via MCIAS", "id", id)
acct, err := ws.mcias.GetAccount(id)
if err != nil {
ws.logger.Warn("webserver: failed to resolve user ID", "id", id, "error", err)
return id
}
ws.logger.Info("webserver: resolved user ID", "id", id, "username", acct.Username)
ws.userCache.Store(id, &cachedUsername{
username: acct.Username,
expiresAt: time.Now().Add(userCacheTTL),
})
return acct.Username
// TODO: re-enable MCIAS account lookup once mcias client library is
// published with proper Go module tags. For now, return the raw ID.
return id
}
// New creates a new WebServer. It dials the vault gRPC endpoint.
@@ -177,22 +163,19 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
}
if tok := cfg.MCIAS.ServiceToken; tok != "" {
mc, err := mcias.New(cfg.MCIAS.ServerURL, mcias.Options{
CACertPath: cfg.MCIAS.CACert,
Token: tok,
})
a, err := mcdslauth.New(mcdslauth.Config{
ServerURL: cfg.MCIAS.ServerURL,
CACert: cfg.MCIAS.CACert,
}, logger)
if err != nil {
logger.Warn("webserver: failed to create MCIAS client for user resolution", "error", err)
logger.Warn("webserver: failed to create auth client for service token validation", "error", err)
} else {
claims, err := mc.ValidateToken(tok)
info, err := a.ValidateToken(tok)
switch {
case err != nil:
logger.Warn("webserver: MCIAS service token validation failed", "error", err)
case !claims.Valid:
logger.Warn("webserver: MCIAS service token is invalid or expired")
default:
logger.Info("webserver: MCIAS service token valid", "sub", claims.Sub, "roles", claims.Roles)
ws.mcias = mc
logger.Info("webserver: MCIAS service token valid", "username", info.Username, "roles", info.Roles)
}
}
}