From 5b43db77bf683db8ec0367b3702bcf40ea6fa522 Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Tue, 10 Feb 2026 09:16:26 +0000 Subject: [PATCH] Implement admin service endpoints and audit log --- backend/services/admin-service/cmd/main.go | 82 +++++++++++- backend/services/admin-service/go.mod | 44 ++++++- backend/services/admin-service/go.sum | 97 +++++++++++++++ .../internal/application/admin/service.go | 58 +++++++++ .../internal/domain/audit/entity.go | 14 +++ .../internal/domain/audit/repository.go | 15 +++ .../internal/infra/config/config.go | 69 +++++++++++ .../infra/persistence/postgres/audit_repo.go | 117 ++++++++++++++++++ .../internal/interfaces/http/handler.go | 103 +++++++++++++++ .../internal/interfaces/http/routes.go | 18 +++ .../tests/integration_http_test.go | 20 +++ 11 files changed, 628 insertions(+), 9 deletions(-) create mode 100644 backend/services/admin-service/internal/application/admin/service.go create mode 100644 backend/services/admin-service/internal/domain/audit/entity.go create mode 100644 backend/services/admin-service/internal/domain/audit/repository.go create mode 100644 backend/services/admin-service/internal/infra/config/config.go create mode 100644 backend/services/admin-service/internal/infra/persistence/postgres/audit_repo.go create mode 100644 backend/services/admin-service/internal/interfaces/http/handler.go create mode 100644 backend/services/admin-service/internal/interfaces/http/routes.go create mode 100644 backend/services/admin-service/tests/integration_http_test.go diff --git a/backend/services/admin-service/cmd/main.go b/backend/services/admin-service/cmd/main.go index a4b916a..ea585e8 100644 --- a/backend/services/admin-service/cmd/main.go +++ b/backend/services/admin-service/cmd/main.go @@ -1,22 +1,92 @@ package main import ( + "context" "log" + "time" + "github.com/gofiber/fiber/v3/middleware/adaptor" + + appadmin "knowfoolery/backend/services/admin-service/internal/application/admin" + adminconfig "knowfoolery/backend/services/admin-service/internal/infra/config" + auditpg "knowfoolery/backend/services/admin-service/internal/infra/persistence/postgres" + httpapi "knowfoolery/backend/services/admin-service/internal/interfaces/http" + "knowfoolery/backend/shared/infra/auth/zitadel" + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" + "knowfoolery/backend/shared/infra/observability/logging" + sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" + "knowfoolery/backend/shared/infra/observability/tracing" "knowfoolery/backend/shared/infra/utils/serviceboot" ) func main() { - cfg := serviceboot.Config{ - AppName: "Know Foolery - Admin Service", + cfg := adminconfig.FromEnv() + logger := logging.NewLogger(cfg.Logging) + metrics := sharedmetrics.NewMetrics(cfg.Metrics) + + tracer, err := tracing.NewTracer(cfg.Tracing) + if err != nil { + logger.Fatal("failed to initialize tracer") + } + defer func() { _ = tracer.Shutdown(context.Background()) }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + db, err := sharedpostgres.NewClient(ctx, cfg.Postgres) + if err != nil { + logger.WithError(err).Fatal("failed to initialize postgres") + } + defer db.Close() + + auditRepo := auditpg.NewAuditRepository(db) + if err := auditRepo.EnsureSchema(ctx); err != nil { + logger.WithError(err).Fatal("failed to ensure audit schema") + } + + svc := appadmin.NewService(auditRepo, cfg.AuditRetentionDays) + // best-effort prune on startup + if _, err := svc.PruneAudit(ctx); err != nil { + logger.WithError(err).Warn("failed to prune audit logs") + } + + h := httpapi.NewHandler(svc, logger, metrics) + + bootCfg := serviceboot.Config{ + AppName: cfg.AppName, ServiceSlug: "admin", PortEnv: "ADMIN_SERVICE_PORT", - DefaultPort: 8085, + DefaultPort: cfg.Port, } + app := serviceboot.NewFiberApp(bootCfg) + serviceboot.RegisterHealth(app, bootCfg.ServiceSlug) + serviceboot.RegisterReadiness( + app, + 2*time.Second, + serviceboot.ReadyCheck{ + Name: "postgres", + Required: true, + Probe: db.Pool.Ping, + }, + ) + app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) + + auth := zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{ + BaseURL: cfg.ZitadelBaseURL, + ClientID: cfg.ZitadelClientID, + ClientSecret: cfg.ZitadelSecret, + Issuer: cfg.ZitadelIssuer, + Audience: cfg.ZitadelAudience, + RequiredClaims: []string{ + "sub", + }, + AdminEndpoints: []string{"/admin"}, + SkipPaths: []string{"/health", "/ready", "/metrics"}, + Timeout: 10 * time.Second, + }) - app := serviceboot.NewFiberApp(cfg) - serviceboot.RegisterHealth(app, cfg.ServiceSlug) + httpapi.RegisterRoutes(app, h, auth) - addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort) + addr := serviceboot.ListenAddress(bootCfg.PortEnv, bootCfg.DefaultPort) log.Fatal(serviceboot.Run(app, addr)) } diff --git a/backend/services/admin-service/go.mod b/backend/services/admin-service/go.mod index 9c8fc86..29484da 100644 --- a/backend/services/admin-service/go.mod +++ b/backend/services/admin-service/go.mod @@ -2,20 +2,58 @@ module knowfoolery/backend/services/admin-service go 1.25.5 -require knowfoolery/backend/shared v0.0.0 +require ( + github.com/gofiber/fiber/v3 v3.0.0-beta.3 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + knowfoolery/backend/shared v0.0.0 +) require ( + github.com/MicahParks/jwkset v0.11.0 // indirect + github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect - github.com/gofiber/fiber/v3 v3.0.0-beta.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/rs/zerolog v1.33.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/admin-service/go.sum b/backend/services/admin-service/go.sum index 44fbc1b..14d9e78 100644 --- a/backend/services/admin-service/go.sum +++ b/backend/services/admin-service/go.sum @@ -1,22 +1,76 @@ +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= +github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg= github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY= github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co= github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -25,9 +79,52 @@ github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8 github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +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= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/services/admin-service/internal/application/admin/service.go b/backend/services/admin-service/internal/application/admin/service.go new file mode 100644 index 0000000..54b7830 --- /dev/null +++ b/backend/services/admin-service/internal/application/admin/service.go @@ -0,0 +1,58 @@ +package admin + +import ( + "context" + "time" + + "github.com/google/uuid" + + "knowfoolery/backend/services/admin-service/internal/domain/audit" +) + +// Service provides admin use-cases. +type Service struct { + auditRepo audit.Repository + retention time.Duration +} + +func NewService(auditRepo audit.Repository, retentionDays int) *Service { + if retentionDays <= 0 { + retentionDays = 90 + } + return &Service{ + auditRepo: auditRepo, + retention: time.Duration(retentionDays) * 24 * time.Hour, + } +} + +func (s *Service) AppendAudit(ctx context.Context, actorID, actorEmail, action, resource string, details any) error { + return s.auditRepo.Append(ctx, audit.Entry{ + ID: uuid.NewString(), + At: time.Now().UTC(), + ActorID: actorID, + ActorEmail: actorEmail, + Action: action, + Resource: resource, + Details: details, + }) +} + +func (s *Service) ListAudit(ctx context.Context, limit, offset int) ([]audit.Entry, error) { + return s.auditRepo.List(ctx, limit, offset) +} + +func (s *Service) Dashboard(ctx context.Context) (map[string]any, error) { + auditCount, err := s.auditRepo.Count(ctx) + if err != nil { + return nil, err + } + return map[string]any{ + "audit_entries_total": auditCount, + "generated_at": time.Now().UTC(), + }, nil +} + +func (s *Service) PruneAudit(ctx context.Context) (int64, error) { + before := time.Now().UTC().Add(-s.retention) + return s.auditRepo.PruneBefore(ctx, before) +} diff --git a/backend/services/admin-service/internal/domain/audit/entity.go b/backend/services/admin-service/internal/domain/audit/entity.go new file mode 100644 index 0000000..29f194e --- /dev/null +++ b/backend/services/admin-service/internal/domain/audit/entity.go @@ -0,0 +1,14 @@ +package audit + +import "time" + +// Entry represents an audit log entry. +type Entry struct { + ID string `json:"id"` + At time.Time `json:"at"` + ActorID string `json:"actor_id"` + ActorEmail string `json:"actor_email"` + Action string `json:"action"` + Resource string `json:"resource"` + Details any `json:"details,omitempty"` +} diff --git a/backend/services/admin-service/internal/domain/audit/repository.go b/backend/services/admin-service/internal/domain/audit/repository.go new file mode 100644 index 0000000..b437c18 --- /dev/null +++ b/backend/services/admin-service/internal/domain/audit/repository.go @@ -0,0 +1,15 @@ +package audit + +import ( + "context" + "time" +) + +// Repository persists audit entries. +type Repository interface { + EnsureSchema(ctx context.Context) error + Append(ctx context.Context, e Entry) error + List(ctx context.Context, limit, offset int) ([]Entry, error) + Count(ctx context.Context) (int64, error) + PruneBefore(ctx context.Context, before time.Time) (int64, error) +} diff --git a/backend/services/admin-service/internal/infra/config/config.go b/backend/services/admin-service/internal/infra/config/config.go new file mode 100644 index 0000000..8805136 --- /dev/null +++ b/backend/services/admin-service/internal/infra/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "time" + + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" + "knowfoolery/backend/shared/infra/observability/logging" + "knowfoolery/backend/shared/infra/observability/metrics" + "knowfoolery/backend/shared/infra/observability/tracing" + "knowfoolery/backend/shared/infra/utils/envutil" +) + +// Config holds runtime configuration for admin-service. +type Config struct { + AppName string + Port int + + Postgres sharedpostgres.Config + Tracing tracing.Config + Metrics metrics.Config + Logging logging.Config + + ZitadelBaseURL string + ZitadelIssuer string + ZitadelAudience string + ZitadelClientID string + ZitadelSecret string + + AuditRetentionDays int + HTTPTimeout time.Duration +} + +// FromEnv builds config from env vars. +func FromEnv() Config { + env := envutil.String("ENVIRONMENT", "development") + serviceName := "admin-service" + + logCfg := logging.DefaultConfig() + logCfg.ServiceName = serviceName + logCfg.Environment = env + logCfg.Level = envutil.String("LOG_LEVEL", logCfg.Level) + + traceCfg := tracing.ConfigFromEnv() + if traceCfg.ServiceName == "knowfoolery" { + traceCfg.ServiceName = serviceName + } + traceCfg.Environment = env + + metricsCfg := metrics.ConfigFromEnv() + if metricsCfg.ServiceName == "knowfoolery" { + metricsCfg.ServiceName = serviceName + } + + return Config{ + AppName: "Know Foolery - Admin Service", + Port: envutil.Int("ADMIN_SERVICE_PORT", 8085), + Postgres: sharedpostgres.ConfigFromEnv(), + Tracing: traceCfg, + Metrics: metricsCfg, + Logging: logCfg, + ZitadelBaseURL: envutil.String("ZITADEL_URL", ""), + ZitadelIssuer: envutil.String("ZITADEL_ISSUER", ""), + ZitadelAudience: envutil.String("ZITADEL_AUDIENCE", ""), + ZitadelClientID: envutil.String("ZITADEL_CLIENT_ID", ""), + ZitadelSecret: envutil.String("ZITADEL_CLIENT_SECRET", ""), + AuditRetentionDays: envutil.Int("ADMIN_AUDIT_RETENTION_DAYS", 90), + HTTPTimeout: envutil.Duration("UPSTREAM_HTTP_TIMEOUT", 3*time.Second), + } +} diff --git a/backend/services/admin-service/internal/infra/persistence/postgres/audit_repo.go b/backend/services/admin-service/internal/infra/persistence/postgres/audit_repo.go new file mode 100644 index 0000000..473f12b --- /dev/null +++ b/backend/services/admin-service/internal/infra/persistence/postgres/audit_repo.go @@ -0,0 +1,117 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "knowfoolery/backend/services/admin-service/internal/domain/audit" + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" +) + +// AuditRepository implements audit.Repository using pgx. +type AuditRepository struct { + db *sharedpostgres.Client +} + +func NewAuditRepository(db *sharedpostgres.Client) *AuditRepository { + return &AuditRepository{db: db} +} + +func (r *AuditRepository) EnsureSchema(ctx context.Context) error { + q := ` +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id TEXT PRIMARY KEY, + at TIMESTAMPTZ NOT NULL, + actor_id TEXT NOT NULL, + actor_email TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + details JSONB NULL +); +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_at ON admin_audit_logs(at DESC); +` + _, err := r.db.Pool.Exec(ctx, q) + return err +} + +func (r *AuditRepository) Append(ctx context.Context, e audit.Entry) error { + var detailsJSON []byte + if e.Details != nil { + b, err := json.Marshal(e.Details) + if err != nil { + return fmt.Errorf("marshal details: %w", err) + } + detailsJSON = b + } + + _, err := r.db.Pool.Exec(ctx, + `INSERT INTO admin_audit_logs(id, at, actor_id, actor_email, action, resource, details) + VALUES($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (id) DO NOTHING`, + e.ID, e.At, e.ActorID, e.ActorEmail, e.Action, e.Resource, detailsJSON, + ) + return err +} + +func (r *AuditRepository) List(ctx context.Context, limit, offset int) ([]audit.Entry, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + + rows, err := r.db.Pool.Query(ctx, + `SELECT id, at, actor_id, actor_email, action, resource, details + FROM admin_audit_logs + ORDER BY at DESC + LIMIT $1 OFFSET $2`, limit, offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []audit.Entry + for rows.Next() { + var e audit.Entry + var detailsBytes []byte + if err := rows.Scan(&e.ID, &e.At, &e.ActorID, &e.ActorEmail, &e.Action, &e.Resource, &detailsBytes); err != nil { + return nil, err + } + if len(detailsBytes) > 0 { + var v any + if err := json.Unmarshal(detailsBytes, &v); err == nil { + e.Details = v + } + } + out = append(out, e) + } + return out, rows.Err() +} + +func (r *AuditRepository) Count(ctx context.Context) (int64, error) { + var n int64 + err := r.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&n) + return n, err +} + +func (r *AuditRepository) PruneBefore(ctx context.Context, before time.Time) (int64, error) { + ct, err := r.db.Pool.Exec(ctx, `DELETE FROM admin_audit_logs WHERE at < $1`, before) + if err != nil { + return 0, err + } + return ct.RowsAffected(), nil +} + +var _ audit.Repository = (*AuditRepository)(nil) + +// Compile-time check for pgx import. +var _ = pgx.ErrNoRows diff --git a/backend/services/admin-service/internal/interfaces/http/handler.go b/backend/services/admin-service/internal/interfaces/http/handler.go new file mode 100644 index 0000000..7c6b16b --- /dev/null +++ b/backend/services/admin-service/internal/interfaces/http/handler.go @@ -0,0 +1,103 @@ +package http + +import ( + "strconv" + + "github.com/gofiber/fiber/v3" + + appadmin "knowfoolery/backend/services/admin-service/internal/application/admin" + "knowfoolery/backend/shared/infra/auth/rbac" + "knowfoolery/backend/shared/infra/auth/zitadel" + "knowfoolery/backend/shared/infra/observability/logging" + "knowfoolery/backend/shared/infra/observability/metrics" + "knowfoolery/backend/shared/infra/utils/httputil" +) + +// Handler wires HTTP endpoints. +type Handler struct { + svc *appadmin.Service + logger *logging.Logger + metrics *metrics.Metrics +} + +func NewHandler(svc *appadmin.Service, logger *logging.Logger, metrics *metrics.Metrics) *Handler { + return &Handler{svc: svc, logger: logger, metrics: metrics} +} + +func (h *Handler) AdminAuth(c fiber.Ctx) error { + actorID := zitadel.GetUserID(c) + actorEmail := zitadel.GetUserEmail(c) + roles := zitadel.GetUserRoles(c) + mfa := zitadel.IsMFAVerified(c) + + _ = h.svc.AppendAudit(c.Context(), actorID, actorEmail, "admin.auth", "admin", map[string]any{ + "mfa": mfa, + "roles": roles, + }) + + return c.JSON(fiber.Map{ + "ok": true, + "actor_id": actorID, + "actor_email": actorEmail, + "roles": roles, + "mfa": mfa, + }) +} + +func (h *Handler) Dashboard(c fiber.Ctx) error { + roles := zitadel.GetUserRoles(c) + if !rbac.UserHasPermission(roles, rbac.PermissionViewDashboard) { + return httputil.Forbidden(c, "dashboard permission required") + } + + actorID := zitadel.GetUserID(c) + actorEmail := zitadel.GetUserEmail(c) + _ = h.svc.AppendAudit(c.Context(), actorID, actorEmail, "admin.dashboard.view", "dashboard", nil) + + resp, err := h.svc.Dashboard(c.Context()) + if err != nil { + h.logger.WithError(err).Error("dashboard failed") + return httputil.InternalError(c, "dashboard failed") + } + return c.JSON(resp) +} + +func (h *Handler) AuditList(c fiber.Ctx) error { + roles := zitadel.GetUserRoles(c) + if !rbac.UserHasPermission(roles, rbac.PermissionViewAuditLog) { + return httputil.Forbidden(c, "audit permission required") + } + + limit := parseIntQuery(c, "limit", 50) + offset := parseIntQuery(c, "offset", 0) + + actorID := zitadel.GetUserID(c) + actorEmail := zitadel.GetUserEmail(c) + _ = h.svc.AppendAudit(c.Context(), actorID, actorEmail, "admin.audit.view", "audit", map[string]any{ + "limit": limit, + "offset": offset, + }) + + entries, err := h.svc.ListAudit(c.Context(), limit, offset) + if err != nil { + h.logger.WithError(err).Error("audit list failed") + return httputil.InternalError(c, "audit list failed") + } + return c.JSON(fiber.Map{ + "items": entries, + "limit": limit, + "offset": offset, + }) +} + +func parseIntQuery(c fiber.Ctx, key string, fallback int) int { + v := c.Query(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} diff --git a/backend/services/admin-service/internal/interfaces/http/routes.go b/backend/services/admin-service/internal/interfaces/http/routes.go new file mode 100644 index 0000000..0488978 --- /dev/null +++ b/backend/services/admin-service/internal/interfaces/http/routes.go @@ -0,0 +1,18 @@ +package http + +import ( + "github.com/gofiber/fiber/v3" +) + +// RegisterRoutes wires HTTP routes. +func RegisterRoutes(app *fiber.App, h *Handler, auth fiber.Handler) { + // Auth middleware if configured + if auth != nil { + app.Use(auth) + } + + admin := app.Group("/admin") + admin.Post("/auth", h.AdminAuth) + admin.Get("/dashboard", h.Dashboard) + admin.Get("/audit", h.AuditList) +} diff --git a/backend/services/admin-service/tests/integration_http_test.go b/backend/services/admin-service/tests/integration_http_test.go new file mode 100644 index 0000000..30f5440 --- /dev/null +++ b/backend/services/admin-service/tests/integration_http_test.go @@ -0,0 +1,20 @@ +package tests + +import ( + "testing" + + "github.com/gofiber/fiber/v3" + + httpapi "knowfoolery/backend/services/admin-service/internal/interfaces/http" + "knowfoolery/backend/shared/infra/observability/logging" + "knowfoolery/backend/shared/infra/observability/metrics" +) + +func TestRegisterRoutesDoesNotPanic(t *testing.T) { + app := fiber.New() + logger := logging.NewLogger(logging.DefaultConfig()) + m := metrics.NewMetrics(metrics.ConfigFromEnv()) + h := httpapi.NewHandler(nil, logger, m) + + httpapi.RegisterRoutes(app, h, nil) +}