Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: ci

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

env:
GO_VERSION: "1.25"

jobs:
build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- run: go mod download
- run: go build ./...
- run: go vet ./...

test:
name: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- run: go test -race -count=1 ./...

lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- uses: golangci/golangci-lint-action@v7
with:
version: latest

tidy:
name: tidy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- run: |
go mod tidy
if ! git diff --exit-code -- go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' locally and commit the result."
exit 1
fi
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/bin/
/dist/
/out/
*.exe
*.test
*.prof
.env
.env.local
coverage.out
coverage.txt
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.7
#
# Multi-stage build for secrets-bridge api. The runtime image is
# distroless/static so the final container has no shell, no package
# manager, and runs as a non-root UID by default.

FROM golang:1.25-alpine AS build
WORKDIR /src

# Cache module downloads across layers.
COPY go.mod go.sum* ./
RUN go mod download

COPY . .

ARG BUILD_VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w -X main.buildVersion=${BUILD_VERSION}" \
-o /out/api \
./cmd/api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /usr/local/bin/api
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/api"]
78 changes: 75 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,79 @@
# secrets-bridge / api

Control Plane API — Go + Fiber. Owns Postgres + Redis + the controlplane/workflow domain.
**Control Plane API for [Secrets Bridge](https://github.com/secrets-bridge)** — Go + Fiber. Owns the workflow / RBAC / audit / metadata domain backed by PostgreSQL and Redis. Agents and the dashboard SPA talk to this service over HTTPS.

🚧 **Scaffold in progress.** See [issue #1](https://github.com/secrets-bridge/api/issues/1) for the open work and acceptance criteria.
## Status

See the [org profile](https://github.com/secrets-bridge) for the platform overview.
| Issue | Step | Status |
|---|---|---|
| [#1](https://github.com/secrets-bridge/api/issues/1) | Scaffold Fiber API + probes + middleware | **this PR** |
| [#2](https://github.com/secrets-bridge/api/issues/2) | Postgres schema + repositories | open |
| [#3](https://github.com/secrets-bridge/api/issues/3) | Redis runtime (locks, idempotency) | open |
| [#4](https://github.com/secrets-bridge/api/issues/4) | Agent registration + heartbeat | open |
| [#5](https://github.com/secrets-bridge/api/issues/5) | Agent job claim/complete loop | open |
| [#6](https://github.com/secrets-bridge/api/issues/6) | Request/approval workflow + audit | open |

## Layout

```
cmd/api/ main + config (the binary)
internal/
handlers/ HTTP layer — thin parse/serialize, calls services
middleware/ requestID, logger, recover, auth/RBAC/audit (stubs today)
observability/ structured logger; metrics + traces land later
services/ business logic — testable in isolation
pkg/
storage/ Postgres repositories + migrations (issue #2)
runtime/ Redis primitives — locks, idempotency, rate limit (issue #3)
workflow/ approval state machine + audit (issue #6)
```

`pkg/*` is the import surface that the `worker` repo will reuse per [REFACTOR_PLAN.md §4](https://github.com/secrets-bridge/.github/blob/main/profile/README.md). `internal/*` is closed.

## Runtime

| Endpoint | Auth | Purpose |
|---|---|---|
| `GET /healthz` | none | Process liveness (kubelet) |
| `GET /readyz` | none | Dependency readiness (kubelet) |
| `GET /metrics` | none | Prometheus exposition (scrape) |
| `/api/v1/*` | OIDC bearer (stub today) | Versioned API surface |

## Configuration

| Env var | Default | Notes |
|---|---|---|
| `API_ADDR` | `:8080` | Listen address |
| `API_SHUTDOWN_GRACE` | `15s` | Graceful-shutdown deadline |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |

Real dependencies (`DATABASE_URL`, `REDIS_URL`, OIDC issuer, etc.) are deliberately absent from this scaffold — they land with the issues that introduce their package.

## Hard rules

These are enforced in code review and (where possible) by CI. Violations block merge.

- **No secret values** anywhere in this service: not in PostgreSQL, not in Redis, not in logs, not in API responses, not in errors, not in audit events. Provider values stay inside their provider and are only touched by the agent.
- **Stateless.** No in-process state that wouldn't survive a pod restart.
- **Every privileged action emits an audit event with a correlation ID.** The audit stub already logs a TODO so missing coverage is visible during development.

## Local development

```bash
go build ./...
go vet ./...
go test -race -count=1 ./...
go run ./cmd/api &
curl -s localhost:8080/healthz
curl -s localhost:8080/readyz
curl -s localhost:8080/metrics | head
```

## Container

```bash
docker build -t secrets-bridge-api:dev .
docker run --rm -p 8080:8080 secrets-bridge-api:dev
```

The image is multi-stage built on `golang:1.24-alpine` and runs on `distroless/static` as the `nonroot` user. No shell, no package manager.
51 changes: 51 additions & 0 deletions cmd/api/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"os"
"time"
)

// buildVersion is set at link time via -ldflags '-X main.buildVersion=...'.
// Defaults to "dev" for `go run` and local builds.
var buildVersion = "dev"

// Config carries the runtime configuration for the api service.
//
// Concrete dependencies (Postgres DSN, Redis URL, OIDC issuer, etc.) are
// deliberately not on this struct yet — they land with their owning
// issue. Keeping Config minimal during the scaffolding phase makes it
// obvious which environment variables actually do something today.
type Config struct {
// Addr is the network address the api listens on, e.g. ":8080".
Addr string

// ShutdownGrace bounds the graceful-shutdown wait.
ShutdownGrace time.Duration
}

func loadConfig() Config {
return Config{
Addr: envOr("API_ADDR", ":8080"),
ShutdownGrace: envDuration("API_SHUTDOWN_GRACE", 15*time.Second),
}
}

func envOr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}

func envDuration(key string, fallback time.Duration) time.Duration {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}

108 changes: 108 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Command api is the Secrets Bridge Control Plane API.
//
// It owns the workflow / RBAC / audit / metadata domain backed by
// PostgreSQL and Redis. Agents and the dashboard SPA communicate with
// this service over HTTPS.
//
// Hard rules (from the project BRD):
// - No secret values are ever stored, logged, or returned by this
// service. Provider values live exclusively in the source provider
// (Vault, AWS Secrets Manager, etc.) and are only touched by the
// agent inside the target boundary.
// - The service is stateless; durable state lives in PostgreSQL,
// short-lived coordination lives in Redis.
// - Every privileged action emits an audit event with a correlation
// ID propagated from the originating request.
package main

import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/gofiber/fiber/v3"

"github.com/secrets-bridge/api/internal/handlers"
"github.com/secrets-bridge/api/internal/middleware"
"github.com/secrets-bridge/api/internal/observability"
)

func main() {
logger := observability.NewLogger(os.Getenv("LOG_LEVEL"))
slog.SetDefault(logger)

cfg := loadConfig()
logger.Info("starting secrets-bridge api",
"version", buildVersion,
"addr", cfg.Addr,
"shutdown_grace", cfg.ShutdownGrace,
)

app := newApp(cfg, logger)

errCh := make(chan error, 1)
go func() {
if err := app.Listen(cfg.Addr); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

select {
case <-ctx.Done():
logger.Info("shutdown signal received, draining connections")
case err := <-errCh:
logger.Error("listener exited", "error", err)
os.Exit(1)
}

shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
defer cancel()
if err := app.ShutdownWithContext(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("shutdown complete")
}

func newApp(cfg Config, logger *slog.Logger) *fiber.App {
app := fiber.New(fiber.Config{
AppName: "secrets-bridge-api",
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
})

// Middleware order is intentional: request ID first so every other
// middleware (including the logger) can read it from the context.
app.Use(middleware.RequestID())
app.Use(middleware.Logger(logger))
app.Use(middleware.Recover(logger))

// Probes and metrics are public; they must answer before auth so
// kubelet and Prometheus can scrape without credentials.
probes := handlers.NewProbes()
app.Get("/healthz", probes.Healthz)
app.Get("/readyz", probes.Readyz)
app.Get("/metrics", handlers.Metrics)

// Authenticated API surface. Auth + RBAC + audit are stub
// placeholders today; real implementations land with workflow
// (issue #6) and storage (issue #2).
v1 := app.Group("/api/v1",
middleware.Auth(),
middleware.RBAC(),
middleware.Audit(logger),
)
_ = v1 // route groups land in follow-up issues; the group is
// registered now so middleware ordering is fixed in this PR.

return app
}
35 changes: 35 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module github.com/secrets-bridge/api

go 1.25.0

require (
github.com/gofiber/fiber/v3 v3.3.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.67.5
)

require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.71.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
Loading
Loading