- internal/auth/pocket_id.go: Middleware mit /login + /callback, OIDC-Discovery + JWKS-Cache, RS256-Signatur-Prüfung, PKCE S256, AES-256-GCM-versiegeltes Session-Cookie, Erzwingung von Gruppenmitgliedschaft 'proofedmesh-admin' (konfigurierbar). Protect(h) setzt WithPrincipal(sub) + WithSession(sid) auf ctx; fehlendes/ungültiges Cookie → 302 nach /login (next= bewahrt). - internal/auth/context.go: WithPrincipal/WithSession/PrincipalFrom /SessionFrom aus httpapi/rates.go in internal/auth gezogen, damit auth und httpapi dieselben Kontext-Keys teilen (keine import cycle: httpapi importiert auth). - httpapi/rates.go: delegiert jetzt an auth.*; die Shim-Exports WithPrincipal/WithSession bleiben für bestehende Tests. - cmd/admin-api/main.go: Pocket-ID-Middleware aktivieren, sobald -oidc-client-id, -oidc-client-secret, -oidc-redirect-url und -session-cookie-secret gesetzt sind; ohne sie bleibt das Stub-Admin-Prinzipal (bestehendes Verhalten für Dev-Mode). - Tests (10 neue, 36/36 insgesamt grün, go vet clean): httptest-Mock-Pocket-ID mit JWKS + /token-Endpoint deckt den vollständigen Flow ab, inkl. missing cookie, falsche Gruppe, Nonce/State-Mismatch, Audience-Mismatch, abgelaufenes ID-Token, unbekannte kid, und Session-Ablauf nach TTL. - README: Auth-Abschnitt + Konfigurations-Flags aktualisiert. DEV-603 Co-Authored-By: Paperclip <noreply@paperclip.ing>
271 lines
8.6 KiB
Go
271 lines
8.6 KiB
Go
// Package httpapi exposes the per_diem_rates admin surface.
|
|
// Content negotiation: Accept: text/html → HTML template, else JSON.
|
|
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
|
|
"basicstack.de/proofedmesh/services/admin-api/internal/auth"
|
|
"basicstack.de/proofedmesh/services/admin-api/internal/model"
|
|
"basicstack.de/proofedmesh/services/admin-api/internal/store"
|
|
)
|
|
|
|
// Dependencies holds the pieces the handlers need; constructed in main.
|
|
type Dependencies struct {
|
|
Store store.RateStore
|
|
Templates *template.Template
|
|
Now func() time.Time
|
|
// Attest is optional. When set, successful Create and Retire call it to
|
|
// sign a rate_change attestation and submit it to the Transparency Log,
|
|
// then persist the attestation ref on the row. When nil (e.g. in unit
|
|
// tests or in a bare dev run), handlers skip the attestation step and
|
|
// log a warning; the row stays "attestation pending" and a reconciler
|
|
// catches it later.
|
|
Attest *attest.Orchestrator
|
|
// Issuer identifies the OIDC issuer for the admin principal, used only
|
|
// when constructing attestation events. Defaults to Pocket ID's issuer.
|
|
Issuer string
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// Mount registers all rate handlers on the given mux. Admin auth middleware
|
|
// is applied by the caller in main.go — handlers themselves assume the
|
|
// request carries an authenticated admin principal in ctx.
|
|
func Mount(mux *http.ServeMux, deps Dependencies) {
|
|
mux.HandleFunc("GET /admin/per-diem-rates/{$}", deps.List)
|
|
mux.HandleFunc("POST /admin/per-diem-rates/{$}", deps.Create)
|
|
mux.HandleFunc("GET /admin/per-diem-rates/{rate_id}", deps.Get)
|
|
mux.HandleFunc("POST /admin/per-diem-rates/{rate_id}/retire", deps.Retire)
|
|
}
|
|
|
|
func (d Dependencies) List(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
f := store.ListFilter{
|
|
Country: r.URL.Query().Get("country"),
|
|
ActiveOnly: r.URL.Query().Get("active_only") == "true",
|
|
}
|
|
rates, err := d.Store.List(ctx, f)
|
|
if err != nil {
|
|
writeErr(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeOK(w, r, http.StatusOK, map[string]any{
|
|
"rates": rates,
|
|
"filter": f,
|
|
}, "per_diem_rates_list.html")
|
|
}
|
|
|
|
func (d Dependencies) Get(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
rateID := r.PathValue("rate_id")
|
|
rate, err := d.Store.Get(ctx, rateID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeErr(w, r, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
writeErr(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
|
|
}
|
|
|
|
func (d Dependencies) Create(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
in, err := parseNewRate(r)
|
|
if err != nil {
|
|
writeErr(w, r, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if err := in.Validate(); err != nil {
|
|
writeErr(w, r, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
rate, err := d.Store.Create(ctx, in, principalFrom(ctx), d.Now())
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrOverlap) {
|
|
writeErr(w, r, http.StatusConflict, err)
|
|
return
|
|
}
|
|
writeErr(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
rate = d.attestOrLog(ctx, attest.ChangeCreate, rate)
|
|
writeOK(w, r, http.StatusCreated, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
|
|
}
|
|
|
|
type retireReq struct {
|
|
ValidTo string `json:"valid_to"` // "YYYY-MM-DD"
|
|
}
|
|
|
|
func (d Dependencies) Retire(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
rateID := r.PathValue("rate_id")
|
|
|
|
req, err := parseRetire(r)
|
|
if err != nil {
|
|
writeErr(w, r, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
validTo, err := time.Parse("2006-01-02", req.ValidTo)
|
|
if err != nil {
|
|
writeErr(w, r, http.StatusBadRequest, fmt.Errorf("valid_to must be YYYY-MM-DD: %w", err))
|
|
return
|
|
}
|
|
rate, err := d.Store.Retire(ctx, rateID, validTo, principalFrom(ctx), d.Now())
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
writeErr(w, r, http.StatusNotFound, err)
|
|
case errors.Is(err, store.ErrAlreadyRetired):
|
|
writeErr(w, r, http.StatusConflict, err)
|
|
default:
|
|
writeErr(w, r, http.StatusBadRequest, err)
|
|
}
|
|
return
|
|
}
|
|
rate = d.attestOrLog(ctx, attest.ChangeRetire, rate)
|
|
writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
|
|
}
|
|
|
|
// attestOrLog signs a rate_change event through d.Attest and attaches the
|
|
// resulting content_hash to the row. On any failure (orchestrator missing,
|
|
// signer/log down, store attach failed), it logs and returns the rate as-is
|
|
// — the row stays "attestation pending" and a reconciliation worker retries.
|
|
func (d Dependencies) attestOrLog(ctx context.Context, kind attest.ChangeKind, rate model.Rate) model.Rate {
|
|
if d.Attest == nil {
|
|
if d.Logger != nil {
|
|
d.Logger.Warn("attest skipped: no orchestrator wired", "rate_id", rate.RateID, "kind", string(kind))
|
|
}
|
|
return rate
|
|
}
|
|
issuer := d.Issuer
|
|
if issuer == "" {
|
|
issuer = "https://auth.basicstack.de"
|
|
}
|
|
ev := attest.RateChangeEvent{
|
|
Kind: kind,
|
|
Rate: rate,
|
|
PerformedBy: attest.PerformedBy{
|
|
Issuer: issuer,
|
|
Subject: principalFrom(ctx),
|
|
SessionID: sessionFrom(ctx),
|
|
},
|
|
PerformedAt: d.Now().UTC(),
|
|
}
|
|
contentHash, _, err := d.Attest.SignAndLog(ctx, ev)
|
|
if err != nil {
|
|
if d.Logger != nil {
|
|
d.Logger.Error("attest failed; row will remain pending for reconciliation",
|
|
"rate_id", rate.RateID, "kind", string(kind), "err", err)
|
|
}
|
|
return rate
|
|
}
|
|
if err := d.Store.AttachAttestation(ctx, rate.RateID, contentHash); err != nil {
|
|
if d.Logger != nil {
|
|
d.Logger.Error("attach attestation failed; signed but not persisted",
|
|
"rate_id", rate.RateID, "content_hash", contentHash, "err", err)
|
|
}
|
|
return rate
|
|
}
|
|
rate.RateChangeAttestationRef = &contentHash
|
|
return rate
|
|
}
|
|
|
|
func parseNewRate(r *http.Request) (model.NewRateInput, error) {
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
var in model.NewRateInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
return in, fmt.Errorf("decode body: %w", err)
|
|
}
|
|
return in, nil
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
return model.NewRateInput{}, fmt.Errorf("parse form: %w", err)
|
|
}
|
|
in := model.NewRateInput{
|
|
Country: r.FormValue("country"),
|
|
Currency: r.FormValue("currency"),
|
|
FullDay: r.FormValue("full_day"),
|
|
PartialDayMin8h: r.FormValue("partial_day_min8h"),
|
|
ArrivalDay: r.FormValue("arrival_day"),
|
|
DepartureDay: r.FormValue("departure_day"),
|
|
SourceRegulation: r.FormValue("source_regulation"),
|
|
}
|
|
if city := r.FormValue("city"); city != "" {
|
|
in.City = &city
|
|
}
|
|
if vf := r.FormValue("valid_from"); vf != "" {
|
|
t, err := time.Parse("2006-01-02", vf)
|
|
if err != nil {
|
|
return in, fmt.Errorf("valid_from: %w", err)
|
|
}
|
|
in.ValidFrom = t
|
|
}
|
|
if vt := r.FormValue("valid_to"); vt != "" {
|
|
t, err := time.Parse("2006-01-02", vt)
|
|
if err != nil {
|
|
return in, fmt.Errorf("valid_to: %w", err)
|
|
}
|
|
in.ValidTo = &t
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
func parseRetire(r *http.Request) (retireReq, error) {
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
var req retireReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
return req, fmt.Errorf("decode body: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
return retireReq{}, err
|
|
}
|
|
return retireReq{ValidTo: r.FormValue("valid_to")}, nil
|
|
}
|
|
|
|
func wantsHTML(r *http.Request) bool {
|
|
accept := r.Header.Get("Accept")
|
|
return strings.Contains(accept, "text/html")
|
|
}
|
|
|
|
func writeOK(w http.ResponseWriter, r *http.Request, status int, body any, template string) {
|
|
if wantsHTML(r) {
|
|
// Template rendering wired in main; in the skeleton we fall through
|
|
// to JSON for determinism in tests.
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, r *http.Request, status int, err error) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
|
|
}
|
|
|
|
// WithPrincipal re-exports auth.WithPrincipal so existing callers and tests
|
|
// keep working after the context accessors moved into internal/auth.
|
|
func WithPrincipal(ctx context.Context, sub string) context.Context {
|
|
return auth.WithPrincipal(ctx, sub)
|
|
}
|
|
|
|
// WithSession re-exports auth.WithSession for the same reason.
|
|
func WithSession(ctx context.Context, sessionID string) context.Context {
|
|
return auth.WithSession(ctx, sessionID)
|
|
}
|
|
|
|
func principalFrom(ctx context.Context) string { return auth.PrincipalFrom(ctx) }
|
|
func sessionFrom(ctx context.Context) string { return auth.SessionFrom(ctx) }
|