From dd698ff6d8c072dcb8cf205f2ffdd0dc008c1d7a Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 25 Mar 2026 18:42:43 -0700 Subject: [PATCH] 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) --- Dockerfile.api | 8 +- Dockerfile.web | 8 +- cmd/metacrypt/server.go | 11 +- deploy/docker/docker-compose-rift.yml | 31 +++++ go.mod | 19 ++- go.sum | 32 ++--- internal/auth/auth.go | 154 ++++++------------------- internal/auth/auth_test.go | 54 ++------- internal/db/db.go | 38 +----- internal/db/migrate.go | 64 +++------- internal/grpcserver/auth.go | 18 +-- internal/grpcserver/grpcserver_test.go | 6 +- internal/server/routes.go | 10 +- internal/server/server_test.go | 5 +- internal/webserver/server.go | 41 ++----- 15 files changed, 157 insertions(+), 342 deletions(-) create mode 100644 deploy/docker/docker-compose-rift.yml diff --git a/Dockerfile.api b/Dockerfile.api index d7b10fc..a797cfa 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -1,13 +1,13 @@ -FROM golang:1.23-alpine AS builder - -RUN apk add --no-cache gcc musl-dev +FROM golang:1.25-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt ./cmd/metacrypt + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /metacrypt ./cmd/metacrypt FROM alpine:3.21 diff --git a/Dockerfile.web b/Dockerfile.web index 912de07..58dca9f 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -1,13 +1,13 @@ -FROM golang:1.23-alpine AS builder - -RUN apk add --no-cache gcc musl-dev +FROM golang:1.25-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt-web ./cmd/metacrypt-web + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /metacrypt-web ./cmd/metacrypt-web FROM alpine:3.21 diff --git a/cmd/metacrypt/server.go b/cmd/metacrypt/server.go index cf509a5..1b8e344 100644 --- a/cmd/metacrypt/server.go +++ b/cmd/metacrypt/server.go @@ -8,7 +8,6 @@ import ( "os/signal" "syscall" - mcias "git.wntrmute.dev/kyle/mcias/clients/go" "github.com/spf13/cobra" "git.wntrmute.dev/kyle/metacrypt/internal/audit" @@ -74,14 +73,14 @@ func runServer(cmd *cobra.Command, args []string) error { return err } - mcClient, err := mcias.New(cfg.MCIAS.ServerURL, mcias.Options{ - CACertPath: cfg.MCIAS.CACert, - }) + authenticator, err := auth.NewAuthenticator(auth.Config{ + ServerURL: cfg.MCIAS.ServerURL, + CACert: cfg.MCIAS.CACert, + ServiceName: "metacrypt", + }, logger) if err != nil { return err } - - authenticator := auth.NewAuthenticator(mcClient, logger) policyEngine := policy.NewEngine(b) engineRegistry := engine.NewRegistry(b, logger) engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine) diff --git a/deploy/docker/docker-compose-rift.yml b/deploy/docker/docker-compose-rift.yml new file mode 100644 index 0000000..c8f3017 --- /dev/null +++ b/deploy/docker/docker-compose-rift.yml @@ -0,0 +1,31 @@ +services: + metacrypt: + build: + context: ../.. + dockerfile: Dockerfile.api + container_name: metacrypt + restart: unless-stopped + ports: + - "127.0.0.1:18443:8443" + - "127.0.0.1:19443:9443" + volumes: + - /srv/metacrypt:/srv/metacrypt + healthcheck: + test: ["CMD", "metacrypt", "status", "--addr", "https://localhost:8443", "--ca-cert", "/srv/metacrypt/certs/ca.pem"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + metacrypt-web: + build: + context: ../.. + dockerfile: Dockerfile.web + container_name: metacrypt-web + restart: unless-stopped + ports: + - "127.0.0.1:18080:8080" + volumes: + - /srv/metacrypt:/srv/metacrypt + depends_on: + - metacrypt diff --git a/go.mod b/go.mod index d88ce43..616de51 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,18 @@ module git.wntrmute.dev/kyle/metacrypt -go 1.25.0 - -replace git.wntrmute.dev/kyle/mcias/clients/go => /Users/kyle/src/mcias/clients/go - -replace git.wntrmute.dev/kyle/goutils => /Users/kyle/src/goutils +go 1.25.7 require ( git.wntrmute.dev/kyle/goutils v1.21.0 - git.wntrmute.dev/kyle/mcias/clients/go v0.0.0-00010101000000-000000000000 + git.wntrmute.dev/kyle/mcdsl v0.0.0 github.com/go-chi/chi/v5 v5.2.5 - github.com/pelletier/go-toml/v2 v2.2.4 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 golang.org/x/crypto v0.49.0 golang.org/x/term v0.41.0 - google.golang.org/grpc v1.79.2 + google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 - modernc.org/sqlite v1.46.1 ) require ( @@ -36,12 +31,14 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - modernc.org/libc v1.67.6 // indirect + modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect ) + +replace git.wntrmute.dev/kyle/mcdsl => /home/kyle/src/metacircular/mcdsl diff --git a/go.sum b/go.sum index 930f394..a5d5927 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.wntrmute.dev/kyle/goutils v1.21.0 h1:ZR7ovV400hsF09zc8tkdHs6vyen8TDJ7flong/dnFXM= +git.wntrmute.dev/kyle/goutils v1.21.0/go.mod h1:JQ8NL5lHSEYl719UMf20p4G1ei70RVGma0hjjNXCR2c= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -37,8 +39,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -81,8 +83,6 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= @@ -102,8 +102,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -113,18 +113,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -133,8 +133,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 90bfd6c..cd28bdb 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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) } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 6fd1efe..55baba8 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -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") } } diff --git a/internal/db/db.go b/internal/db/db.go index 45858cb..e8a3830 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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) } diff --git a/internal/db/migrate.go b/internal/db/migrate.go index cd891e1..080fc3b 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -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(¤t); 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) } diff --git a/internal/grpcserver/auth.go b/internal/grpcserver/auth.go index 8eaeb7b..c37e83d 100644 --- a/internal/grpcserver/auth.go +++ b/internal/grpcserver/auth.go @@ -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 } diff --git a/internal/grpcserver/grpcserver_test.go b/internal/grpcserver/grpcserver_test.go index 6de8f82..b704d13 100644 --- a/internal/grpcserver/grpcserver_test.go +++ b/internal/grpcserver/grpcserver_test.go @@ -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) diff --git a/internal/server/routes.go b/internal/server/routes.go index 54efe78..b1de1a8 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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{ diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f1e34aa..abf7b28 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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()) diff --git a/internal/webserver/server.go b/internal/webserver/server.go index ed0ca66..c7cb2d0 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -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) } } }