From 146e1bc6ca97dd376f9431cbdc360523d4404673 Mon Sep 17 00:00:00 2001 From: Khalefa Date: Mon, 8 Jun 2026 01:54:29 +0300 Subject: [PATCH 1/2] =?UTF-8?q?test(integration):=20docker-compose=20harne?= =?UTF-8?q?ss=20for=20standalone=E2=86=92central=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained multi-container harness under test/integration/ (does NOT touch the production docker-compose.yml). Phase 1: a Postgres-backed central serving HTTPS + a BoltDB local-accounts standalone that migrates into it over real cross-container TLS. - docker-compose.yml: postgres + central (TLS, cert SAN=central/localhost) + standalone-local (HTTP, trusts the internal CA via SSL_CERT_FILE). - certs/gen.sh: internal CA + central cert (generated certs are gitignored). - driver_test.go (//go:build integration): seeds the standalone, mints a token on the central, asserts the cleartext-http push is refused, runs preflight+commit over TLS, verifies the import landed, and logs the migrated user into the CENTRAL with her carried password hash — asserting roles, permissions, and the CARRIED audience. - Makefile (make up / test / down), README. Excluded from the normal build by the `integration` tag, so `go build/vet/test ./...` (the release gate) is unaffected. Verified green end-to-end locally. Phase 2 (planned): LDAP/AD nodes for same-AD (policy-only), different-AD (blocked) and a direct app. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/integration/Makefile | 33 ++++ test/integration/README.md | 57 +++++++ test/integration/certs/.gitignore | 5 + test/integration/certs/gen.sh | 28 ++++ test/integration/docker-compose.yml | 77 +++++++++ test/integration/driver_test.go | 236 ++++++++++++++++++++++++++++ 6 files changed, 436 insertions(+) create mode 100644 test/integration/Makefile create mode 100644 test/integration/README.md create mode 100644 test/integration/certs/.gitignore create mode 100755 test/integration/certs/gen.sh create mode 100644 test/integration/docker-compose.yml create mode 100644 test/integration/driver_test.go diff --git a/test/integration/Makefile b/test/integration/Makefile new file mode 100644 index 0000000..6b741a8 --- /dev/null +++ b/test/integration/Makefile @@ -0,0 +1,33 @@ +# SimpleAuth integration-test harness — self-contained, does NOT touch the +# production docker-compose.yml at the repo root. +COMPOSE := docker compose -f docker-compose.yml +CADIR := $(CURDIR)/certs + +.PHONY: certs up down test logs ps clean + +## certs: generate the internal CA + central TLS cert (idempotent) +certs: + @./certs/gen.sh + +## up: stand up the topology (browse https://localhost:9443/admin) +up: certs + $(COMPOSE) up -d --build + +## down: stop everything and wipe volumes +down: + $(COMPOSE) down -v --remove-orphans + +## ps / logs: inspect the running stack +ps: + $(COMPOSE) ps +logs: + $(COMPOSE) logs -f + +## test: up + run the Go scenario driver (from the repo root) + tear down +test: up + @cd ../.. && ITEST_CA_FILE=$(CADIR)/ca.crt go test -tags integration -count=1 -v ./test/integration/ ; \ + rc=$$? ; $(COMPOSE) down -v --remove-orphans ; exit $$rc + +## clean: down + remove generated certs +clean: down + @rm -f certs/ca.crt certs/ca.key certs/central.crt certs/central.key diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 0000000..7c4cf3a --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,57 @@ +# Integration harness — standalone → central migration + +A self-contained, multi-container environment that exercises the v2.2 migration +feature end to end, across real container boundaries (real TLS, real HTTP push) — +the parts the Go unit tests can't reach. **It does not touch the production +`docker-compose.yml` at the repo root.** + +```bash +cd test/integration +make test # up + run the Go scenario driver + tear down +make up # just stand up the env; browse https://localhost:9443/admin +make down # tear down + wipe volumes +make logs # follow logs +``` + +Requires Docker + Compose + openssl + Go (the driver runs on the host). + +## Topology (phase 1) + +| Service | Role | Store | TLS | +|---|---|---|---| +| `postgres` | central's database | — | — | +| `central` | the ONE centralized SimpleAuth | Postgres | serves HTTPS (cert SAN=`central`,`localhost`,`127.0.0.1`) | +| `standalone-local` | a local-accounts deployment that migrates in | BoltDB | plain HTTP internally | + +Published to the host (loopback only): central `https://localhost:9443`, +standalone `http://localhost:9444`. + +### Cross-container TLS + +The migration's transport guard requires **https for any non-loopback target**, +and the standalone's outbound client verifies the cert. So `certs/gen.sh` mints an +internal CA + a `central` cert; the standalone trusts the CA via +`SSL_CERT_FILE=/certs/ca.crt`. The standalone pushes to `https://central:8080` +(the internal name in the cert's SANs). This makes the harness validate the real +secure path, not a bypass. + +## What the driver asserts (`driver_test.go`, `//go:build integration`) + +`TestLocalToCentralMigration`: +1. seeds the standalone (role catalog + a local user `alice` with role `admin`); +2. on the central, creates a fresh target app `billing` and mints a single-use + migration token; +3. confirms a **cleartext `http://` push is refused** (the transport guard); +4. runs **preflight** (dry run) over TLS and checks the report (1 local user, none + blocked); +5. **commits**, and verifies `alice → [admin]` landed in the central's `billing` + authz; +6. logs `alice` in **against the central** with her original password (carried + hash) and asserts the token carries `roles=[admin]`, `perms` incl. + `invoice:write`, `aud=billing`. + +## Phase 2 (planned) + +Add `ldap-corp` / `ldap-other` (seeded with `sAMAccountName`/`memberOf` via LDIF) +and standalones for: **same-AD** (policy-only migration, AD re-bind), **different +AD** (preflight blocks), and a **direct app** registered straight on the central. diff --git a/test/integration/certs/.gitignore b/test/integration/certs/.gitignore new file mode 100644 index 0000000..1401f72 --- /dev/null +++ b/test/integration/certs/.gitignore @@ -0,0 +1,5 @@ +# Generated by gen.sh — never commit keys/certs. +*.crt +*.key +*.csr +*.srl diff --git a/test/integration/certs/gen.sh b/test/integration/certs/gen.sh new file mode 100755 index 0000000..cb92e14 --- /dev/null +++ b/test/integration/certs/gen.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Generates an internal CA + a TLS cert for the `central` service. The SANs cover +# the compose service name (`central`, used by standalones pushing over the +# internal network) AND loopback (`localhost`/`127.0.0.1`, used by the host-side +# driver), so both validate against the same CA. Idempotent — delete *.crt/*.key +# to regenerate. +set -euo pipefail +cd "$(dirname "$0")" + +if [[ -f ca.crt && -f central.crt && -f central.key ]]; then + echo "certs already present (delete certs/*.crt certs/*.key to regenerate)" + exit 0 +fi + +echo "generating internal CA + central cert..." +openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 \ + -subj "/CN=SimpleAuth Integration Test CA" >/dev/null 2>&1 + +openssl req -newkey rsa:2048 -nodes -keyout central.key -out central.csr \ + -subj "/CN=central" >/dev/null 2>&1 + +ext="$(mktemp)" +printf "subjectAltName=DNS:central,DNS:localhost,IP:127.0.0.1\n" > "$ext" +openssl x509 -req -in central.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out central.crt -days 3650 -extfile "$ext" >/dev/null 2>&1 +rm -f central.csr ca.srl "$ext" + +echo " -> certs/ca.crt, certs/central.crt, certs/central.key (SAN: central, localhost, 127.0.0.1)" diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml new file mode 100644 index 0000000..a181f14 --- /dev/null +++ b/test/integration/docker-compose.yml @@ -0,0 +1,77 @@ +# ============================================================================= +# SimpleAuth — integration test topology (SELF-CONTAINED; not the prod compose) +# ============================================================================= +# Phase 1: a centralized SimpleAuth (Postgres + HTTPS) and a standalone (BoltDB, +# local accounts) that migrates INTO it over real cross-container TLS. +# +# make up stand up the environment (browse https://localhost:9443/admin) +# make test up + run the Go scenario driver + tear down +# make down tear down + wipe volumes +# +# The standalone pushes to https://central:8080 (the internal name) and validates +# the central's cert via the internal CA mounted at /certs/ca.crt (SSL_CERT_FILE). +# ============================================================================= + +name: simpleauth-itest + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: itest + POSTGRES_DB: simpleauth + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d simpleauth"] + interval: 3s + timeout: 3s + retries: 30 + + # The ONE centralized SimpleAuth: Postgres-backed, serves its own HTTPS. + central: + build: + context: ../.. + dockerfile: Dockerfile + image: simpleauth:itest + environment: + AUTH_ADMIN_KEY: central-admin-key + AUTH_HOSTNAME: central + AUTH_PORT: "8080" + AUTH_BASE_PATH: "/" + AUTH_JWT_ISSUER: central + AUTH_POSTGRES_URL: "postgres://postgres:itest@postgres:5432/simpleauth?sslmode=disable" + AUTH_TLS_CERT: /certs/central.crt + AUTH_TLS_KEY: /certs/central.key + volumes: + - ./certs/central.crt:/certs/central.crt:ro + - ./certs/central.key:/certs/central.key:ro + ports: + - "127.0.0.1:9443:8080" + depends_on: + postgres: + condition: service_healthy + + # A standalone deployment with LOCAL accounts (no AD), BoltDB. Serves plain HTTP + # on the internal network; trusts the central's CA for the outbound migration. + standalone-local: + image: simpleauth:itest + environment: + AUTH_ADMIN_KEY: local-admin-key + AUTH_HOSTNAME: standalone-local + AUTH_PORT: "8080" + AUTH_HTTP_PORT: "8080" + AUTH_TLS_DISABLED: "true" + AUTH_BASE_PATH: "/" + AUTH_JWT_ISSUER: standalone-local + AUTH_DATA_DIR: /data + # Trust the central's internal CA so the HTTPS migration push validates. + SSL_CERT_FILE: /certs/ca.crt + volumes: + - standalone-local-data:/data + - ./certs/ca.crt:/certs/ca.crt:ro + ports: + - "127.0.0.1:9444:8080" + depends_on: + - central + +volumes: + standalone-local-data: diff --git a/test/integration/driver_test.go b/test/integration/driver_test.go new file mode 100644 index 0000000..1242cbe --- /dev/null +++ b/test/integration/driver_test.go @@ -0,0 +1,236 @@ +//go:build integration + +// Package integration drives the multi-container topology in docker-compose.yml. +// It is excluded from the normal build/test by the `integration` tag; run it with +// `make test` (which stands up the stack and points ITEST_CA_FILE at the CA). +package integration + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "simpleauth/internal/migrate" +) + +func env(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} + +var ( + centralURL = env("ITEST_CENTRAL_URL", "https://127.0.0.1:9443") + centralInternal = env("ITEST_CENTRAL_INTERNAL", "https://central:8080") + standaloneURL = env("ITEST_STANDALONE_URL", "http://127.0.0.1:9444") + centralKey = env("ITEST_CENTRAL_KEY", "central-admin-key") + standaloneKey = env("ITEST_STANDALONE_KEY", "local-admin-key") +) + +// node is a thin admin-API client for one SimpleAuth instance. +type node struct { + base, key string + c *http.Client +} + +func (n *node) do(t *testing.T, method, path string, body any) (int, []byte) { + t.Helper() + var r io.Reader + if body != nil { + b, _ := json.Marshal(body) + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, n.base+path, r) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if n.key != "" { + req.Header.Set("Authorization", "Bearer "+n.key) + } + resp, err := n.c.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp.StatusCode, data +} + +func (n *node) must(t *testing.T, method, path string, body any) []byte { + t.Helper() + code, data := n.do(t, method, path, body) + if code < 200 || code >= 300 { + t.Fatalf("%s %s -> %d: %s", method, path, code, data) + } + return data +} + +// caClient trusts the integration CA so the host-side driver can validate the +// central's HTTPS cert. +func caClient() *http.Client { + pool, _ := x509.SystemCertPool() + if pool == nil { + pool = x509.NewCertPool() + } + if pem, err := os.ReadFile(env("ITEST_CA_FILE", "test/integration/certs/ca.crt")); err == nil { + pool.AppendCertsFromPEM(pem) + } + return &http.Client{Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}} +} + +func waitReady(t *testing.T, name, base string, c *http.Client) { + t.Helper() + deadline := time.Now().Add(120 * time.Second) + for time.Now().Before(deadline) { + if resp, err := c.Get(base + "/health"); err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + } + time.Sleep(2 * time.Second) + } + t.Fatalf("%s not ready at %s", name, base) +} + +func tokenClaims(t *testing.T, jwt string) (roles, perms, aud []string) { + t.Helper() + parts := strings.Split(jwt, ".") + if len(parts) < 2 { + t.Fatalf("malformed jwt") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode jwt payload: %v", err) + } + var c struct { + Roles []string `json:"roles"` + Perms []string `json:"permissions"` + Aud any `json:"aud"` + } + if err := json.Unmarshal(payload, &c); err != nil { + t.Fatalf("unmarshal claims: %v", err) + } + switch a := c.Aud.(type) { + case string: + aud = []string{a} + case []any: + for _, x := range a { + if s, ok := x.(string); ok { + aud = append(aud, s) + } + } + } + return c.Roles, c.Perms, aud +} + +func has(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// TestLocalToCentralMigration: a local-accounts standalone migrates into a fresh +// app on the central over real cross-container TLS; the migrated user then logs +// in AGAINST THE CENTRAL with the same password and gets the right roles/perms. +func TestLocalToCentralMigration(t *testing.T) { + central := &node{centralURL, centralKey, caClient()} + standalone := &node{standaloneURL, standaloneKey, &http.Client{Timeout: 20 * time.Second}} + + waitReady(t, "central", centralURL, central.c) + waitReady(t, "standalone-local", standaloneURL, standalone.c) + + // --- seed the standalone: role catalog + a local user with a role --- + standalone.must(t, "PUT", "/api/admin/permissions", []string{"invoice:read", "invoice:write"}) + standalone.must(t, "PUT", "/api/admin/role-permissions", map[string][]string{ + "clerk": {"invoice:read"}, + "admin": {"invoice:read", "invoice:write"}, + }) + var u struct { + GUID string `json:"guid"` + } + json.Unmarshal(standalone.must(t, "POST", "/api/admin/users", map[string]any{ + "display_name": "Alice", "password": "alicepass123", + }), &u) + standalone.must(t, "PUT", "/api/admin/users/"+u.GUID+"/mappings", map[string]any{"provider": "local", "external_id": "alice"}) + standalone.must(t, "PUT", "/api/admin/users/"+u.GUID+"/roles", []string{"admin"}) + // Give the standalone's home app a distinctive audience so we can prove the + // migration CARRIES it onto the target (the consumer app keeps its own aud). + standalone.must(t, "PUT", "/api/admin/apps/simpleauth", map[string]any{"audience": "standalone-app-aud"}) + + // --- central: create the target app + mint a single-use migration token --- + central.must(t, "POST", "/api/admin/apps", map[string]any{"app_id": "billing", "audience": "billing"}) + var tok struct { + Token string `json:"migration_token"` + } + json.Unmarshal(central.must(t, "POST", "/api/admin/apps/billing/migration-token", nil), &tok) + if tok.Token == "" { + t.Fatal("central returned no migration token") + } + + // --- guard: a cleartext http push to the (non-loopback) central is refused --- + if code, _ := standalone.do(t, "POST", "/api/admin/migrate-to-central/preflight", map[string]any{ + "central_url": "http://central:8080", "app_id": "billing", "token": tok.Token, + }); code != http.StatusBadRequest { + t.Fatalf("cleartext http migration push must be 400, got %d", code) + } + + mig := map[string]any{"central_url": centralInternal, "app_id": "billing", "token": tok.Token, "carry_secret": true} + + // --- preflight (dry run) over real TLS --- + var rep migrate.Report + json.Unmarshal(standalone.must(t, "POST", "/api/admin/migrate-to-central/preflight", mig), &rep) + if !rep.OK() || rep.LocalUsers != 1 { + t.Fatalf("preflight: want 1 local user and nothing blocked; got %+v", rep) + } + + // --- commit --- + var res migrate.ApplyResult + json.Unmarshal(standalone.must(t, "POST", "/api/admin/migrate-to-central/commit", mig), &res) + if res.AssignmentsSet != 1 || res.LocalUsersCreated != 1 { + t.Fatalf("commit result: %+v", res) + } + + // --- the import landed on the central --- + var az struct { + UserAssignments map[string][]string `json:"user_assignments"` + } + json.Unmarshal(central.must(t, "GET", "/api/admin/apps/billing/authz", nil), &az) + if got := az.UserAssignments["alice"]; len(got) != 1 || got[0] != "admin" { + t.Fatalf("central billing assignment for alice = %v, want [admin]", got) + } + + // --- the migrated user authenticates AGAINST THE CENTRAL (carried hash) --- + var login struct { + AccessToken string `json:"access_token"` + } + json.Unmarshal(central.must(t, "POST", "/api/auth/login", map[string]any{ + "username": "alice", "password": "alicepass123", "app_id": "billing", + }), &login) + roles, perms, aud := tokenClaims(t, login.AccessToken) + if !has(roles, "admin") { + t.Fatalf("central token roles = %v, want admin", roles) + } + if !has(perms, "invoice:write") { + t.Fatalf("central token perms = %v, want invoice:write", perms) + } + // aud is the CARRIED source audience, not "billing" — that is the guarantee + // that lets the consumer app keep its existing token validation after cutover. + if !has(aud, "standalone-app-aud") { + t.Fatalf("central token aud = %v, want carried source audience standalone-app-aud", aud) + } + t.Logf("OK: standalone-local migrated into central; alice authenticates on central roles=%v perms=%v aud=%v (carried)", roles, perms, aud) +} From b5008a56dee1a13c93aaf3567ab331655f7a019a Mon Sep 17 00:00:00 2001 From: Khalefa Date: Mon, 8 Jun 2026 03:05:39 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(integration):=20phase=202=20=E2=80=94?= =?UTF-8?q?=20AD=20scenarios=20(same-AD=20/=20different-AD=20/=20direct)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the harness with two OpenLDAP directories and AD-backed standalones, proving the AD half of the migration matrix end to end: - ldap-corp (corp.local) + ldap-other (other.local): vanilla OpenLDAP seeded via LDIF. SimpleAuth uses username_attr=uid, so User.SAMAccountName comes from `uid` and no samba schema is needed; AD users are provisioned via login/JIT. - standalone-ad (same AD as central) + standalone-addiff (different AD). - ad_test.go (TestADMigrationScenarios): * same-AD -> POLICY-ONLY migration (no record/password copied, local_users=0); the AD user re-binds from the same AD on the central and resolves his role. * different-AD -> blocked at preflight + commit refused (409). * direct app -> an app registered straight on the central; AD user authenticates directly (no migration). Also: phase-1 driver now asserts the CARRIED source audience (not the target app id), and the README/topology reflect the full topology. All scenarios green; release gate (`go build/vet/test ./...`) unaffected (the driver is behind the `integration` tag). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/integration/README.md | 38 +++++-- test/integration/ad_test.go | 168 ++++++++++++++++++++++++++++ test/integration/docker-compose.yml | 69 ++++++++++++ test/integration/ldap/corp.ldif | 14 +++ test/integration/ldap/other.ldif | 14 +++ 5 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 test/integration/ad_test.go create mode 100644 test/integration/ldap/corp.ldif create mode 100644 test/integration/ldap/other.ldif diff --git a/test/integration/README.md b/test/integration/README.md index 7c4cf3a..f92b736 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -15,16 +15,19 @@ make logs # follow logs Requires Docker + Compose + openssl + Go (the driver runs on the host). -## Topology (phase 1) +## Topology -| Service | Role | Store | TLS | -|---|---|---|---| -| `postgres` | central's database | — | — | -| `central` | the ONE centralized SimpleAuth | Postgres | serves HTTPS (cert SAN=`central`,`localhost`,`127.0.0.1`) | -| `standalone-local` | a local-accounts deployment that migrates in | BoltDB | plain HTTP internally | +| Service | Role | Store / dir | +|---|---|---| +| `postgres` | central's database | — | +| `central` | the ONE centralized SimpleAuth (HTTPS, cert SAN=`central`,`localhost`) | Postgres | +| `standalone-local` | local-accounts deployment → migrates in | BoltDB | +| `standalone-ad` | **same-AD** deployment → policy-only migration | BoltDB + corp.local | +| `standalone-addiff` | **different-AD** deployment → blocked at preflight | BoltDB + other.local | +| `ldap-corp` / `ldap-other` | OpenLDAP directories (`uid`/seed LDIF) | corp.local / other.local | Published to the host (loopback only): central `https://localhost:9443`, -standalone `http://localhost:9444`. +standalone-local `:9444`, standalone-ad `:9445`, standalone-addiff `:9446`. ### Cross-container TLS @@ -48,10 +51,21 @@ secure path, not a bypass. authz; 6. logs `alice` in **against the central** with her original password (carried hash) and asserts the token carries `roles=[admin]`, `perms` incl. - `invoice:write`, `aud=billing`. + `invoice:write`, and the **carried source audience** (not the target app id — + that's what lets the consumer app keep its existing token validation). -## Phase 2 (planned) +## AD scenarios (`ad_test.go`) -Add `ldap-corp` / `ldap-other` (seeded with `sAMAccountName`/`memberOf` via LDIF) -and standalones for: **same-AD** (policy-only migration, AD re-bind), **different -AD** (preflight blocks), and a **direct app** registered straight on the central. +`TestADMigrationScenarios` drives the AD half of the matrix against the two +OpenLDAP directories. SimpleAuth is pointed at `username_attr=uid`, so +`User.SAMAccountName` is populated from `uid` and vanilla OpenLDAP suffices (no +samba schema); AD users are provisioned via the login/JIT path. + +- **same-AD** — `bob` logs into `standalone-ad`, gets a role, and is migrated to + the central **policy-only** (no record/password copied, `local_users=0`); he + then **re-binds from the same AD on the central** and resolves his migrated + role. +- **different-AD** — `carol` (other.local) is **blocked at preflight** (the + central is on corp.local) and commit is refused (409). +- **direct app** — an app registered straight on the central; `bob` authenticates + directly against it (no migration). diff --git a/test/integration/ad_test.go b/test/integration/ad_test.go new file mode 100644 index 0000000..968d5d8 --- /dev/null +++ b/test/integration/ad_test.go @@ -0,0 +1,168 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "simpleauth/internal/migrate" +) + +// ldapConfig builds the SimpleAuth LDAP config payload. username_attr=uid means +// User.SAMAccountName is populated from `uid` — so vanilla OpenLDAP works and the +// migration (which keys on sAMAccountName) sees the uid value. +func ldapConfig(url, baseDN, domain string) map[string]any { + return map[string]any{ + "url": url, "base_dn": baseDN, "bind_dn": "cn=admin," + baseDN, + "bind_password": "adminpw", "username_attr": "uid", + "display_name_attr": "cn", "email_attr": "mail", "groups_attr": "memberOf", + "use_tls": false, "allow_insecure": true, "domain": domain, + } +} + +// loginRetry retries an LDAP login until it succeeds (OpenLDAP takes a while to +// bootstrap its seed LDIF on first boot) and returns the access token. +func loginRetry(t *testing.T, n *node, username, password, appID string) string { + t.Helper() + body := map[string]any{"username": username, "password": password} + if appID != "" { + body["app_id"] = appID + } + deadline := time.Now().Add(90 * time.Second) + for { + code, data := n.do(t, "POST", "/api/auth/login", body) + if code == http.StatusOK { + var r struct { + AccessToken string `json:"access_token"` + } + json.Unmarshal(data, &r) + return r.AccessToken + } + if time.Now().After(deadline) { + t.Fatalf("login %q never succeeded (last %d: %s)", username, code, data) + } + time.Sleep(3 * time.Second) + } +} + +func findGUIDBySAM(t *testing.T, n *node, sam string) string { + t.Helper() + var users []struct { + GUID string `json:"guid"` + SAM string `json:"sam_account_name"` + } + json.Unmarshal(n.must(t, "GET", "/api/admin/users", nil), &users) + for _, u := range users { + if u.SAM == sam { + return u.GUID + } + } + t.Fatalf("user with sAMAccountName=%q not found on %s", sam, n.base) + return "" +} + +// TestADMigrationScenarios covers the AD half of the matrix against real +// OpenLDAP directories: a same-AD policy-only migration (AD user re-binds on the +// central), a different-AD migration that is blocked at preflight, and an app +// registered directly on the central. +func TestADMigrationScenarios(t *testing.T) { + central := &node{centralURL, centralKey, caClient()} + sAD := &node{env("ITEST_STANDALONE_AD_URL", "http://127.0.0.1:9445"), "ad-admin-key", &http.Client{Timeout: 20 * time.Second}} + sDiff := &node{env("ITEST_STANDALONE_ADDIFF_URL", "http://127.0.0.1:9446"), "addiff-admin-key", &http.Client{Timeout: 20 * time.Second}} + + waitReady(t, "central", centralURL, central.c) + waitReady(t, "standalone-ad", sAD.base, sAD.c) + waitReady(t, "standalone-addiff", sDiff.base, sDiff.c) + + // central + standalone-ad share corp.local; standalone-addiff is on other.local. + central.must(t, "PUT", "/api/admin/ldap", ldapConfig("ldap://ldap-corp:389", "dc=corp,dc=local", "corp.local")) + sAD.must(t, "PUT", "/api/admin/ldap", ldapConfig("ldap://ldap-corp:389", "dc=corp,dc=local", "corp.local")) + sDiff.must(t, "PUT", "/api/admin/ldap", ldapConfig("ldap://ldap-other:389", "dc=other,dc=local", "other.local")) + + t.Run("same_ad_policy_migration", func(t *testing.T) { + sAD.must(t, "PUT", "/api/admin/permissions", []string{"hr:read", "hr:write"}) + sAD.must(t, "PUT", "/api/admin/role-permissions", map[string][]string{"editor": {"hr:read", "hr:write"}}) + + // bob (AD) logs into the standalone -> JIT-provisioned; give him a role. + loginRetry(t, sAD, "bob", "bobpass", "") + bob := findGUIDBySAM(t, sAD, "bob") + sAD.must(t, "PUT", "/api/admin/users/"+bob+"/roles", []string{"editor"}) + + central.must(t, "POST", "/api/admin/apps", map[string]any{"app_id": "hr", "audience": "hr"}) + var tok struct { + Token string `json:"migration_token"` + } + json.Unmarshal(central.must(t, "POST", "/api/admin/apps/hr/migration-token", nil), &tok) + mig := map[string]any{"central_url": centralInternal, "app_id": "hr", "token": tok.Token, "carry_secret": true} + + // Policy-only: an AD user, no local-user record/password is copied. + var rep migrate.Report + json.Unmarshal(sAD.must(t, "POST", "/api/admin/migrate-to-central/preflight", mig), &rep) + if !rep.OK() || rep.ADUsersSameDomain != 1 || rep.LocalUsers != 0 { + t.Fatalf("preflight: want 1 same-AD user, 0 local, none blocked; got %+v", rep) + } + var res migrate.ApplyResult + json.Unmarshal(sAD.must(t, "POST", "/api/admin/migrate-to-central/commit", mig), &res) + if res.AssignmentsSet != 1 || res.LocalUsersCreated != 0 { + t.Fatalf("commit (policy-only) result: %+v", res) + } + + var az struct { + UserAssignments map[string][]string `json:"user_assignments"` + } + json.Unmarshal(central.must(t, "GET", "/api/admin/apps/hr/authz", nil), &az) + if got := az.UserAssignments["bob"]; len(got) != 1 || got[0] != "editor" { + t.Fatalf("central hr assignment for bob = %v, want [editor]", got) + } + + // bob RE-BINDS from the SAME AD on the central and resolves his migrated role. + roles, perms, _ := tokenClaims(t, loginRetry(t, central, "bob", "bobpass", "hr")) + if !has(roles, "editor") || !has(perms, "hr:write") { + t.Fatalf("central hr token roles=%v perms=%v, want editor/hr:write", roles, perms) + } + t.Logf("OK same-AD: bob migrated policy-only, re-binds on central roles=%v perms=%v", roles, perms) + }) + + t.Run("different_ad_blocked", func(t *testing.T) { + sDiff.must(t, "PUT", "/api/admin/role-permissions", map[string][]string{"viewer": {}}) + loginRetry(t, sDiff, "carol", "carolpass", "") + carol := findGUIDBySAM(t, sDiff, "carol") + sDiff.must(t, "PUT", "/api/admin/users/"+carol+"/roles", []string{"viewer"}) + + central.must(t, "POST", "/api/admin/apps", map[string]any{"app_id": "diffapp", "audience": "diffapp"}) + var tok struct { + Token string `json:"migration_token"` + } + json.Unmarshal(central.must(t, "POST", "/api/admin/apps/diffapp/migration-token", nil), &tok) + mig := map[string]any{"central_url": centralInternal, "app_id": "diffapp", "token": tok.Token} + + var rep migrate.Report + json.Unmarshal(sDiff.must(t, "POST", "/api/admin/migrate-to-central/preflight", mig), &rep) + if rep.OK() || len(rep.Blocked) == 0 { + t.Fatalf("different-AD must block carol at preflight; got %+v", rep) + } + // And commit is refused (the central re-runs the classifier). + if code, data := sDiff.do(t, "POST", "/api/admin/migrate-to-central/commit", mig); code != http.StatusConflict { + t.Fatalf("different-AD commit must be 409, got %d: %s", code, data) + } + t.Logf("OK different-AD: carol blocked at preflight + commit (%d blocked)", len(rep.Blocked)) + }) + + t.Run("direct_app_on_central", func(t *testing.T) { + central.must(t, "POST", "/api/admin/apps", map[string]any{"app_id": "portal", "audience": "portal"}) + central.must(t, "PUT", "/api/admin/apps/portal/authz", map[string]any{ + "roles": []string{"viewer"}, + "role_permissions": map[string][]string{"viewer": {"portal:read"}}, + "user_assignments": map[string][]string{"bob": {"viewer"}}, + }) + // bob (corp AD) authenticates DIRECTLY against the central for portal. + roles, perms, aud := tokenClaims(t, loginRetry(t, central, "bob", "bobpass", "portal")) + if !has(roles, "viewer") || !has(perms, "portal:read") || !has(aud, "portal") { + t.Fatalf("direct portal token roles=%v perms=%v aud=%v", roles, perms, aud) + } + t.Logf("OK direct: bob authenticates directly on central for portal roles=%v", roles) + }) +} diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index a181f14..5359076 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -73,5 +73,74 @@ services: depends_on: - central + # --- Phase 2: AD scenarios ------------------------------------------------ + # corp.local — the SHARED directory for the central + the same-AD standalone. + ldap-corp: + image: osixia/openldap:1.5.0 + command: ["--copy-service"] + environment: + LDAP_ORGANISATION: "Corp" + LDAP_DOMAIN: "corp.local" + LDAP_ADMIN_PASSWORD: "adminpw" + volumes: + - ./ldap/corp.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/01-users.ldif:ro + + # other.local — a DIFFERENT directory; its users are unsatisfiable on the central. + ldap-other: + image: osixia/openldap:1.5.0 + command: ["--copy-service"] + environment: + LDAP_ORGANISATION: "Other" + LDAP_DOMAIN: "other.local" + LDAP_ADMIN_PASSWORD: "adminpw" + volumes: + - ./ldap/other.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/01-users.ldif:ro + + # An AD-backed standalone on the SAME AD as the central -> policy-only migration. + standalone-ad: + image: simpleauth:itest + environment: + AUTH_ADMIN_KEY: ad-admin-key + AUTH_HOSTNAME: standalone-ad + AUTH_PORT: "8080" + AUTH_HTTP_PORT: "8080" + AUTH_TLS_DISABLED: "true" + AUTH_BASE_PATH: "/" + AUTH_JWT_ISSUER: standalone-ad + AUTH_DATA_DIR: /data + SSL_CERT_FILE: /certs/ca.crt + volumes: + - standalone-ad-data:/data + - ./certs/ca.crt:/certs/ca.crt:ro + ports: + - "127.0.0.1:9445:8080" + depends_on: + - central + - ldap-corp + + # An AD-backed standalone on a DIFFERENT AD -> preflight must block its users. + standalone-addiff: + image: simpleauth:itest + environment: + AUTH_ADMIN_KEY: addiff-admin-key + AUTH_HOSTNAME: standalone-addiff + AUTH_PORT: "8080" + AUTH_HTTP_PORT: "8080" + AUTH_TLS_DISABLED: "true" + AUTH_BASE_PATH: "/" + AUTH_JWT_ISSUER: standalone-addiff + AUTH_DATA_DIR: /data + SSL_CERT_FILE: /certs/ca.crt + volumes: + - standalone-addiff-data:/data + - ./certs/ca.crt:/certs/ca.crt:ro + ports: + - "127.0.0.1:9446:8080" + depends_on: + - central + - ldap-other + volumes: standalone-local-data: + standalone-ad-data: + standalone-addiff-data: diff --git a/test/integration/ldap/corp.ldif b/test/integration/ldap/corp.ldif new file mode 100644 index 0000000..48dd521 --- /dev/null +++ b/test/integration/ldap/corp.ldif @@ -0,0 +1,14 @@ +# Seed for the corp.local directory (shared by `central` and `standalone-ad`, +# i.e. the SAME-AD scenario). Vanilla OpenLDAP attrs — SimpleAuth is pointed at +# username_attr=uid, so User.SAMAccountName is populated from `uid`. +dn: ou=users,dc=corp,dc=local +objectClass: organizationalUnit +ou: users + +dn: uid=bob,ou=users,dc=corp,dc=local +objectClass: inetOrgPerson +cn: Bob Builder +sn: Builder +uid: bob +mail: bob@corp.local +userPassword: bobpass diff --git a/test/integration/ldap/other.ldif b/test/integration/ldap/other.ldif new file mode 100644 index 0000000..bd9ff15 --- /dev/null +++ b/test/integration/ldap/other.ldif @@ -0,0 +1,14 @@ +# Seed for the other.local directory (used by `standalone-addiff`, the +# DIFFERENT-AD scenario — the central is on corp.local, so these users are +# unsatisfiable there and preflight must block them). +dn: ou=users,dc=other,dc=local +objectClass: organizationalUnit +ou: users + +dn: uid=carol,ou=users,dc=other,dc=local +objectClass: inetOrgPerson +cn: Carol Other +sn: Other +uid: carol +mail: carol@other.local +userPassword: carolpass