Implement admin service endpoints and audit log

master
EC2 Default User 1 month ago
parent e4bb5cd0fd
commit 5b43db77bf

@ -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))
}

@ -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

@ -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=

@ -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)
}

@ -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"`
}

@ -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)
}

@ -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),
}
}

@ -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

@ -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
}

@ -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)
}

@ -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)
}
Loading…
Cancel
Save