iam: one IAM — drop v1 and the "iam2" name
There is one identity service. The clean-room rewrite already replaced the Casdoor fork in this repo — the tree has no object/, controllers/ or web/, LICENSE is Hanzo proprietary, and no source file carries a Casdoor copyright — but the name never followed. 65 files still said "iam2", the binary was /iam2, and the store was iam2.db, which reads as though a second service exists somewhere. Renamed throughout: command, app name, DB path, prose. Zero "iam2" left. cmd/migrate-v1 is deleted with the v1 service it read from. It existed to copy a legacy Casdoor SQLite store into the clean-room one; with v1 dropped there is no source to migrate. MIGRATION.md goes with it — it described the migration as upcoming work, and it was the last file carrying Casdoor's copyright line. internal/cred stays. It resolves the password algorithm from the stored row and verifies argon2id as well as bcrypt; every row written by the old service is argon2id, and dropping that would lock those accounts out. That is credential support, not v1 code. The server doc comment claimed iam runs "ALONGSIDE the live Casdoor /v1/iam/*" under a shadow prefix. That was the cutover plan and is no longer true; it now describes the prefix as the caller's choice, normally canonical. Builds clean, 26 test packages pass, gofmt clean. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+2
-2
@@ -1,7 +1,7 @@
|
||||
# binaries
|
||||
/iam2
|
||||
/iam
|
||||
/iam-v2
|
||||
iam2
|
||||
iam
|
||||
iam-v2
|
||||
/iam
|
||||
/iamd
|
||||
|
||||
+9
-9
@@ -1,11 +1,11 @@
|
||||
# Hanzo IAM v2 — proprietary identity service (zip + orm, no Casdoor).
|
||||
# Hanzo IAM — proprietary identity service (zip + orm, no Casdoor).
|
||||
# Multi-stage Go build → distroless-style alpine. Pure-Go (CGO_ENABLED=0);
|
||||
# hanzoai/sqlite uses the modernc engine so no cgo/musl toolchain is needed.
|
||||
|
||||
FROM golang:1.26.4@sha256:f96cc555eb8db430159a3aa6797cd5bae561945b7b0fe7d0e284c63a3b291609 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache the module graph before copying the source. iam2 imports private hanzoai
|
||||
# Cache the module graph before copying the source. iam imports private hanzoai
|
||||
# modules (hanzoai/orm, hanzoai/sqlite), so mark them private (direct fetch, no
|
||||
# sumdb) and — when a GIT_AUTH_TOKEN is mounted — rewrite github.com to an
|
||||
# authenticated fetch so `go mod download` can read them. Same pattern as
|
||||
@@ -26,20 +26,20 @@ ARG GO_EXPERIMENT=jsonv2
|
||||
ENV GOEXPERIMENT=${GO_EXPERIMENT}
|
||||
|
||||
ARG VERSION=dev
|
||||
# Two binaries from one build stage: the server (/out/iam2) and the Phase-5
|
||||
# Two binaries from one build stage: the server (/out/iam) and the Phase-5
|
||||
# cutover migrator (/out/migrate-v1) the migration Job runs. Both are pure-Go
|
||||
# (CGO_ENABLED=0 + the same GOEXPERIMENT) and share the one module download above.
|
||||
# The migrator carries no version symbol, so it is stamped -s -w only.
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/iam2 . \
|
||||
-o /out/iam . \
|
||||
&& CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o /out/migrate-v1 ./cmd/migrate-v1
|
||||
|
||||
FROM alpine:latest@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS STANDARD
|
||||
LABEL org.opencontainers.image.source="https://github.com/hanzoai/iam"
|
||||
LABEL org.opencontainers.image.title="Hanzo IAM v2"
|
||||
LABEL org.opencontainers.image.title="Hanzo IAM"
|
||||
# sqlcipher is the C SQLCipher 4.x shell the migrator's --wal-inclusive path drives
|
||||
# to checkpoint each shard's uncheckpointed -wal before extraction; alpine ships
|
||||
# SQLCipher 4.x (4.5.6 on the stable branch, 4.6.x on edge), whose v4 on-disk
|
||||
@@ -53,12 +53,12 @@ RUN apk add --no-cache ca-certificates sqlcipher && update-ca-certificates \
|
||||
&& mkdir -p /data && chown -R hanzo:hanzo /data
|
||||
USER 1000
|
||||
WORKDIR /
|
||||
COPY --from=build --chown=hanzo:hanzo /out/iam2 /iam2
|
||||
COPY --from=build --chown=hanzo:hanzo /out/iam /iam
|
||||
COPY --from=build --chown=hanzo:hanzo /out/migrate-v1 /migrate-v1
|
||||
|
||||
# Serves the IAM v2 API over ZAP (:9653) + the HTTP edge (:8080). Bootstrap the
|
||||
# Serves the IAM API over ZAP (:9653) + the HTTP edge (:8080). Bootstrap the
|
||||
# config with --init-data /etc/iam/init_data.json (mounted from the same
|
||||
# init_data ConfigMap the Casdoor iam uses; ${VAR} creds from the KMS-synced env).
|
||||
EXPOSE 8080 9653
|
||||
ENTRYPOINT ["/iam2"]
|
||||
CMD ["serve", "--db", "/data/iam2.db", "--http", "http://:8080", "--zap", ":9653"]
|
||||
ENTRYPOINT ["/iam"]
|
||||
CMD ["serve", "--db", "/data/iam.db", "--http", "http://:8080", "--zap", ":9653"]
|
||||
|
||||
@@ -15,7 +15,7 @@ hand-rolling OAuth. Full SDK model: `~/work/hanzo/SDK-ARCHITECTURE.md`.
|
||||
- `go build ./...`
|
||||
- `go run . serve --init-data init_data.json` (SQLite default; `--store sqlite|sql|datastore`)
|
||||
- `go run . compare --legacy postgres://…/iam` (needs `-tags migration`)
|
||||
- Image: `ghcr.io/hanzoai/iam2`. Embed via `server.Route`. Go 1.26.
|
||||
- Image: `ghcr.io/hanzoai/iam`. Embed via `server.Route`. Go 1.26.
|
||||
|
||||
## Endpoints (HIP-0111 — /v1 only, no /api, no vendor verbs)
|
||||
`/.well-known/openid-configuration` · `/v1/iam/.well-known/jwks` ·
|
||||
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
# IAM v2 Migration
|
||||
|
||||
Casdoor fork (`hanzoai/iam`: Beego + xorm, Apache-2.0) → `hanzoai/iam2`:
|
||||
clean-room, proprietary, on the native Hanzo stack. Phased and additive — the
|
||||
identity binary is never rewritten in one shot, and v1 stays live and
|
||||
authoritative until the supervised cutover. Parity is proven by tests + golden
|
||||
vectors captured from v1's own code + a route-level parity audit, and by a
|
||||
shadow deployment against real traffic — not by a swap on faith.
|
||||
|
||||
## §1 Why
|
||||
|
||||
`hanzoai/iam` is a fork of Casdoor. Every file carries `Portions Copyright The
|
||||
Casdoor Authors` under Apache-2.0. It couples us to xorm's fluent API, Beego's
|
||||
router, and an upstream we do not control. `iam2` is original expression on our
|
||||
own framework — we own it, and it collapses to one way of doing each thing.
|
||||
|
||||
## §2 Stack contract
|
||||
|
||||
- **HTTP** — `github.com/zap-proto/zip` (typed `zip.Get[In,Out]` handlers on the
|
||||
`zap-proto/fiber/v3` engine, specificity routing, OpenAPI 3.1 at the edge).
|
||||
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache). Default
|
||||
is embedded SQLite (`hanzoai/sqlite`, pure-Go, WAL) — never Postgres. The same
|
||||
`orm.DB` abstraction pluggably targets `hanzoai/sql` / `hanzoai/datastore` over
|
||||
ZAP (`--store sql|datastore`), so iam2 gains ZAP-native persistence + snapshots
|
||||
with zero code change once orm's ZAP backend is enabled.
|
||||
- **OIDC/OAuth2** — in-tree (no external OIDC library). RS256 today; ML-DSA-65
|
||||
hybrid JWT signing + real JWKS from the Cert entity.
|
||||
- **Password verify** — algorithm resolved from the stored row (`internal/cred`):
|
||||
argon2id (every live v1 row) + bcrypt (new iam2 rows), verify-only, fail-closed.
|
||||
- **Inter-service** — `zap-proto` binary RPC. HTTPS is the external edge only; all
|
||||
service↔service is ZAP (platform law).
|
||||
- **Authz** — `github.com/hanzoai/authz` policy engine (`internal/authz` gate).
|
||||
|
||||
## §2.1 RFC/IETF-standard surface — no Casdoor verbs (HIP-0111)
|
||||
|
||||
The HTTP contract is RFC/OpenID-standard only; there are no Casdoor verb aliases
|
||||
(`get-users`, `add-user`, `get-account`, `issue-user-token`, …) and no `access_token`
|
||||
duplicate of the token endpoint. Each capability is served by its standard, all
|
||||
shipped (iam2 tags):
|
||||
|
||||
| Capability | Standard | Endpoint | Tag |
|
||||
|-----------|----------|----------|-----|
|
||||
| Authorize / token | RFC 6749 (code+PKCE, refresh, client_credentials, **password**) | `/v1/iam/oauth/{authorize,token}` | v0.5.0 |
|
||||
| Delegation / on-behalf-of | **RFC 8693 Token Exchange** (replaces `issue-user-token`) | `grant_type=…token-exchange` | v0.7.0 |
|
||||
| Introspection / revocation | RFC 7662 / RFC 7009 | `/v1/iam/oauth/{introspect,revoke}` | v0.6.0 |
|
||||
| AS metadata / discovery / JWKS | RFC 8414 / OIDC Discovery / RFC 7517 | `/.well-known/*` | v0.6.0 |
|
||||
| Account claims | **OIDC UserInfo** (carries owner/organization/email/isAdmin/type — the get-account contract) | `/v1/iam/oauth/userinfo` | v0.9.0 |
|
||||
| Identity provisioning | **SCIM 2.0** (RFC 7644/7643; replaces get-/add-/update-/delete-user) | `/v1/iam/scim/v2/Users` | v0.8.0 (v0.8.1 authz fix) |
|
||||
| Resource indicators / issuer pin | RFC 8707 + `IAM_ISSUER` | token `aud`/`iss` | v0.5.0 |
|
||||
| Social sign-in / federation | **OIDC/OAuth2 Relying Party** (Authorization-Code + PKCE; Google = OIDC Discovery, GitHub = OAuth2 + userinfo) | authorize `?provider=<name>` → `/v1/iam/oauth/callback` | v0.15.0 |
|
||||
|
||||
Deploy env: `IAM_ISSUER=https://<brand-id>`, `IAM_KEY_MINT_ALLOWED_APPS` (token
|
||||
exchange + `hk-` key mint) and `IAM_ADMIN_MINT_ALLOWED_APPS` (reserved-org targets)
|
||||
— both matched by the globally-unique clientId only.
|
||||
|
||||
**Federation (social sign-in), v0.15.0.** iam2 completes a Google/GitHub sign-in
|
||||
as a standard OIDC/OAuth2 Relying Party — no Casdoor verbs, no tokens-in-query.
|
||||
The authorize endpoint, once it has validated the client and its EXACT
|
||||
redirect_uri, treats a `?provider=<providerName>` request as a federation
|
||||
kickoff: it stashes the app-leg request in a single-use, expiring,
|
||||
browser-bound `FederationState` (state = an opaque 256-bit row key; a `hanzo_fed`
|
||||
HttpOnly+Secure+SameSite=Lax cookie binds it to the initiating browser) and 302s
|
||||
to the IdP with iam2's callback as the redirect_uri, an IdP-leg S256 PKCE
|
||||
verifier, and (OIDC) a nonce. The fixed public callback `/v1/iam/oauth/callback`
|
||||
resolves + burns the transaction (expiry + browser-binding checked), exchanges
|
||||
the IdP code, and VERIFIES the response — for OIDC the id_token signature
|
||||
(against the discovered JWKS, alg pinned to RS/ES), issuer, audience (= our
|
||||
client id), expiry, and nonce; for GitHub the userinfo + a GitHub-verified
|
||||
primary email. It then LINKS or PROVISIONS a local user (match by provider
|
||||
subject, else by VERIFIED email, else provision — never `isAdmin`, federated
|
||||
accounts carry no password) and mints iam2's OWN authorization code bound to the
|
||||
original PKCE/redirect/nonce, so the relying party's existing PKCE code→token
|
||||
exchange completes unchanged. Provider credentials/endpoints come from the
|
||||
existing `providers` rows (`clientId`/`clientSecret`/`type`/`scopes`/`issuerUrl`
|
||||
or the `custom*Url` overrides); the linked subject is persisted on the User's
|
||||
per-connector column (`google`/`github`/…).
|
||||
|
||||
Remaining for cutover: migrate the clients (console `IamAdminApi`/`identity.ts`,
|
||||
gateway admin-guard, portal) off the Casdoor verbs onto these standards via
|
||||
`@hanzo/iam` (+ a SCIM client + token-exchange), then retire `internal/compat` and
|
||||
`get-account`. iam2 already serves everything the clients need in standard form.
|
||||
|
||||
## §3 Phases
|
||||
|
||||
| Phase | Scope | Exit |
|
||||
|------:|-------|------|
|
||||
| 1 | Entity schemas (full fields) + owner-scoped CRUD on `zip`+`orm`, 13 identity entities. | ✅ Field-complete vs v1; handlers tested. |
|
||||
| 2 | In-tree OIDC/OAuth2: discovery, JWKS, authorize, token (PKCE S256 + JWT), refresh, userinfo, logout; front-door login/get-app-login/auth-methods. | ✅ Core flow (login→code→token→JWT) tested; front-door residual in progress (below). |
|
||||
| 3 | Authz via `hanzoai/authz` gate over the entity CRUD. | ✅ In `internal/authz`. |
|
||||
| — | ~~Drift gate~~ **DROPPED.** Parity is proven by tests + golden vectors (a real v1 argon2id digest verifies) + a route-level parity audit + a shadow deployment — not a row-count diff. The read-only `compare` CLI remains as a diagnostic, not a gate. | — |
|
||||
| 4 | **Bootstrap + embed.** Seed the real config (orgs/apps/providers/certs) from the same `init_data.json` v1 uses (`internal/seed` — 79 apps / 9 orgs). Embed in `hanzoai/cloud` via `server.Route`, SHADOW-FIRST (own prefix, alongside live Casdoor, non-destructive). | Shadow serves real `get-app-login`/login against seeded config. |
|
||||
| 5 | **Cutover.** Import the user rows (password hashes verify as-is — see §5), flip iam2 onto the canonical `/v1/iam/*`, archive the fork. | Green in prod; rollback proven. |
|
||||
|
||||
## §4 Front-door residual (gates cutover)
|
||||
|
||||
The OIDC/OAuth2 protocol surface is complete. HIP-0111 §6's *native front-door* —
|
||||
what the hosted `hanzo.id` portal itself calls, distinct from the OIDC surface
|
||||
client apps use — is now complete: `get-app-login`, `login`, `auth/methods`,
|
||||
`userinfo`, `logout`, `refresh`, `authorize`, `get-account`,
|
||||
`send-verification-code`, `signup`. A backend swap without these takes the
|
||||
portal's account page, email verification, and signup with it, so cutover was
|
||||
gated on them. Serve under `/v1/iam/*` (no `/api/`, no new prefix).
|
||||
|
||||
The **durable session** is bound (`internal/sessions`): a bare `login`
|
||||
(type=login) issues a signed, revocable session cookie (`hanzo_session`, HMAC
|
||||
keyed off the platform signing cert — no new secret), and `get-account` resolves
|
||||
the caller by cookie first (the portal + admin-guard path) then bearer (the API
|
||||
path) — two credentials, one identity. The cookie's `sid` is registered in the
|
||||
`Session` row and re-checked on every resolve, so logout/rotation revokes it.
|
||||
§4 is closed; iam2 is Phase-4 shadow-embed ready.
|
||||
|
||||
The `signup`/`send-verification-code` pair carries two deliberate seams vs v1,
|
||||
each a missing iam2 dependency, not a shortcut: (1) signup lands the user in the
|
||||
app's **existing** org — v1's founder-org mint (`TenantOrgForSignup`) needs an
|
||||
org-create helper + the `Org.Parent` tenant model iam2 has not modeled yet;
|
||||
(2) `send-verification-code` persists a verifiable OTP (the `verifications`
|
||||
entity) but the email/SMS **delivery** is owned by `hanzoai/notify`, not bound
|
||||
into iam2 — the endpoint reports `ok` honestly and never fakes a "sent" claim.
|
||||
|
||||
Three facts the port must honour, each verified against live v1:
|
||||
- **`get-account` is a security contract, not a convenience.** The gateway's
|
||||
admin-guard derives the **SuperAdmin predicate** from it
|
||||
(`gateway/cmd/admin-guard/main.go`); waitlist-guard derives **approval**. Its
|
||||
response shape (owner/isAdmin/… + no secret material) must match exactly.
|
||||
- **`send-verification-code` takes `multipart/form-data`, not JSON.**
|
||||
- Native **`userinfo`/`logout` are aliases** of the `oauth/*` handlers
|
||||
(`routers/router.go` + `authz_filter.go` collapse them) — register the alias,
|
||||
never fork a second implementation.
|
||||
|
||||
## §5 Credential parity (the cutover landmine, RESOLVED)
|
||||
|
||||
Every live v1 row is **argon2id** (`object/organization.go sanitizeOrgPasswordType`
|
||||
rewrites `""`/`bcrypt`/`plain` → `argon2id`; `UpdateUserPassword` stamps it per
|
||||
user). A bcrypt-only verifier handed an argon2id PHC digest returns
|
||||
`ErrHashTooShort` → **100% of logins fail at cutover.** Fixed: `internal/cred`
|
||||
resolves the algorithm **from the row** (`user.PasswordType` → fallback
|
||||
`organization.PasswordType`), matching v1's `object/check.go`, and verifies
|
||||
argon2id + bcrypt, verify-only, fail-closed on any unknown scheme. Proven by a
|
||||
**golden vector** — a digest produced by v1's *own* `Argon2idCredManager`
|
||||
verifies under iam2 (`internal/cred/golden_v1_test.go`), across the v0→v1.0.0
|
||||
library-version gap. So existing users' hashes verify unchanged at import — no
|
||||
password reset, no re-hash on read.
|
||||
|
||||
## §6 Domain model (v1 xorm table → v2 orm kind)
|
||||
|
||||
Fourteen identity entities. Field-completeness is mandatory — a dropped column is
|
||||
lost auth data.
|
||||
|
||||
| v1 table (xorm) | v2 orm kind |
|
||||
|-----------------------|------------------------|
|
||||
| `user` | `users` (auth) |
|
||||
| `organization` | `organizations` |
|
||||
| `application` | `applications` |
|
||||
| `provider` | `providers` |
|
||||
| `role` | `roles` |
|
||||
| `permission` | `permissions` |
|
||||
| `cert` | `certs` |
|
||||
| `key` | `keys` |
|
||||
| `webauthn_credential` | `webauthn_credentials` |
|
||||
| `session` | `sessions` |
|
||||
| `token` | `tokens` |
|
||||
| `record` | `audit_logs` |
|
||||
| `invitation` | `invitations` |
|
||||
| `verification` | `verifications` |
|
||||
|
||||
**Deliberately NOT modeled by iam2** (they belong to other services or are
|
||||
replaced by `hanzoai/authz`): `payment`, `plan`, `product`, `subscription`,
|
||||
`pricing`, `model`, `adapter`, `enforcer`, `syncer_*`, LDAP.
|
||||
|
||||
## §7 Build & deploy
|
||||
|
||||
Builds CGO-free (`hanzoai/sqlite` is pure-Go), pinned to published `hanzoai/orm`
|
||||
+ `zap-proto/zip` (no local replaces). Native CI at `.gitea/workflows/build.yaml`
|
||||
(git.hanzo.ai act_runner) + a mirror `.github/workflows/build.yml`, both
|
||||
self-contained (no reusable-workflow dependency). Canonical pipeline is
|
||||
**git.hanzo.ai + Hanzo GitOps**; GitHub is a downstream mirror.
|
||||
@@ -91,7 +91,7 @@ go run . version
|
||||
globally-unique `client_id`).
|
||||
|
||||
The service is embeddable via `server.Route` and builds on Hanzo CI
|
||||
(`ghcr.io/hanzoai/iam2`).
|
||||
(`ghcr.io/hanzoai/iam`).
|
||||
|
||||
## Client auth (HIP-0111)
|
||||
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/sqlcipher"
|
||||
hsqlite "github.com/hanzoai/sqlite"
|
||||
|
||||
"github.com/hanzoai/iam/internal/cred"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// sqlcipherInteropKey is the fixed raw key testdata/c-4.5.6.db was written under
|
||||
// by the real C libsqlcipher 4.5.6 (see testdata/README.txt). The test decrypts
|
||||
// that vector ONLY to obtain a reserved-page (header byte 20 == 80) plaintext
|
||||
// canvas, which pure Go cannot originate; the canvas's own schema is discarded.
|
||||
var sqlcipherInteropKey = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
|
||||
func mustHex(s string) []byte {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// reservedCanvas returns a fresh reserved-page PLAINTEXT SQLite database: the
|
||||
// DecryptFile output of the C-written interop vector. modernc preserves its
|
||||
// 80-byte reserve on write, so seeding a schema into it and re-EncryptFile'ing
|
||||
// yields an encrypted shard the test fully controls — exercising the real
|
||||
// DeriveKey→UnwrapDEK→DecryptFile decrypt chain without any prod data.
|
||||
func reservedCanvas(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
enc, err := os.ReadFile(filepath.Join("testdata", "c-4.5.6.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("read canvas fixture: %v", err)
|
||||
}
|
||||
var plain bytes.Buffer
|
||||
if err := sqlcipher.DecryptFile(&plain, bytes.NewReader(enc), sqlcipher.RawKey(sqlcipherInteropKey), sqlcipher.Params{}); err != nil {
|
||||
t.Fatalf("decrypt canvas fixture: %v", err)
|
||||
}
|
||||
if b := plain.Bytes(); len(b) < 21 || b[20] != sqlcipher.Reserve {
|
||||
t.Fatalf("canvas is not reserved (header byte 20 = %d, want %d)", plain.Bytes()[20], sqlcipher.Reserve)
|
||||
}
|
||||
return plain.Bytes()
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
// dropAllTables clears the canvas's inherited schema so the test starts clean.
|
||||
func dropAllTables(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
t.Fatalf("list canvas tables: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatalf("scan table name: %v", err)
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
rows.Close()
|
||||
for _, n := range names {
|
||||
mustExec(t, db, `DROP TABLE IF EXISTS "`+n+`"`)
|
||||
}
|
||||
}
|
||||
|
||||
// writeEncryptedShard produces one encrypted, envelope-wrapped shard at dbPath
|
||||
// (+ dbPath+".dek"): seed a schema into a reserved canvas, EncryptFile it under a
|
||||
// fresh random DEK, then WRAP that DEK under the KEK derived from (master, pt,
|
||||
// pid) — the exact inverse of decryptToTemp, so migrate-v1's encrypted path reads
|
||||
// it back byte-for-byte.
|
||||
func writeEncryptedShard(t *testing.T, dbPath string, master []byte, pt hsqlite.PrincipalType, pid string, seed func(*sql.DB)) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, reservedCanvas(t), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open canvas: %v", err)
|
||||
}
|
||||
dropAllTables(t, db)
|
||||
seed(db)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close canvas: %v", err)
|
||||
}
|
||||
|
||||
plain, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain[20] != sqlcipher.Reserve {
|
||||
t.Fatalf("modernc dropped the reserve (byte 20 = %d): cannot EncryptFile", plain[20])
|
||||
}
|
||||
|
||||
dek := make([]byte, 32)
|
||||
if _, err := rand.Read(dek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enc bytes.Buffer
|
||||
if err := sqlcipher.EncryptFile(&enc, bytes.NewReader(plain), sqlcipher.RawKey(dek), nil, sqlcipher.Params{}); err != nil {
|
||||
t.Fatalf("encrypt shard: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, enc.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
kek, err := hsqlite.DeriveKey(master, pt, pid)
|
||||
if err != nil {
|
||||
t.Fatalf("derive KEK: %v", err)
|
||||
}
|
||||
wrapped, err := hsqlite.WrapDEK(kek, dek, hsqlite.PrincipalAAD(pt, pid))
|
||||
if err != nil {
|
||||
t.Fatalf("wrap DEK: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath+".dek", wrapped, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildEncryptedDatadir lays out a sharded encrypted source: a GLOBAL shard
|
||||
// (two orgs + a cert) and two PER-ORG shards (hanzo/z, acme/root), each user
|
||||
// carrying the golden argon2id digest. It returns the datadir and the cert's
|
||||
// PEM material so the caller can assert verbatim key survival.
|
||||
func buildEncryptedDatadir(t *testing.T, master []byte) (datadir, certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
datadir = t.TempDir()
|
||||
certPEM, keyPEM = genRSAPEM(t)
|
||||
|
||||
// GLOBAL shard: orgs + cert, principal (global, "iam").
|
||||
writeEncryptedShard(t, filepath.Join(datadir, "iam.db"), master, hsqlite.PrincipalGlobal, globalPrincipalID, func(db *sql.DB) {
|
||||
mustExec(t, db, `CREATE TABLE "organization"(owner text, name text, created_time text, display_name text, password_type text, init_score integer)`)
|
||||
mustExec(t, db, `INSERT INTO "organization" VALUES(?,?,?,?,?,?)`, "admin", "hanzo", "2020-01-02T03:04:05Z", "Hanzo", "argon2id", 100)
|
||||
mustExec(t, db, `INSERT INTO "organization" VALUES(?,?,?,?,?,?)`, "admin", "acme", "2020-01-03T03:04:05Z", "Acme", "argon2id", 0)
|
||||
mustExec(t, db, `CREATE TABLE "cert"(owner text, name text, created_time text, type text, crypto_algorithm text, bit_size integer, certificate text, private_key text)`)
|
||||
mustExec(t, db, `INSERT INTO "cert" VALUES(?,?,?,?,?,?,?,?)`, "admin", "cert-hanzo", "2020-01-02T03:04:05Z", "x509", "RS256", 2048, certPEM, keyPEM)
|
||||
})
|
||||
|
||||
// PER-ORG shard hanzo: user z (own argon2id type), principal (org, "hanzo").
|
||||
writeEncryptedShard(t, filepath.Join(datadir, "orgs", "hanzo", "iam.db"), master, hsqlite.PrincipalOrg, "hanzo", func(db *sql.DB) {
|
||||
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, password_salt text, email text, display_name text, is_admin integer)`)
|
||||
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
"hanzo", "z", "2020-01-02T03:04:05Z", "uuid-0001", goldenDigest, "argon2id", "the-salt", "z@hanzo.ai", "Z", 1)
|
||||
})
|
||||
|
||||
// PER-ORG shard acme: user root, principal (org, "acme") — proves shard MERGE
|
||||
// and per-org KEK isolation (its DEK is wrapped under acme's KEK, not hanzo's).
|
||||
writeEncryptedShard(t, filepath.Join(datadir, "orgs", "acme", "iam.db"), master, hsqlite.PrincipalOrg, "acme", func(db *sql.DB) {
|
||||
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, password_salt text, email text, display_name text, is_admin integer)`)
|
||||
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
"acme", "root", "2020-01-02T03:04:05Z", "uuid-0002", goldenDigest, "argon2id", "salt2", "root@acme.io", "Root", 1)
|
||||
})
|
||||
|
||||
return datadir, certPEM, keyPEM
|
||||
}
|
||||
|
||||
// TestEncryptedSource_GoldenChain is the end-to-end credential-parity proof for
|
||||
// the ENCRYPTED, SHARDED source path: DeriveKey → UnwrapDEK → DecryptFile →
|
||||
// Migrate → cred.Verify, across a global shard and two org shards, with the
|
||||
// golden argon2id digest verifying under the clean verifier after it entered the
|
||||
// clean store ONLY by being decrypted from an encrypted shard. It also proves the
|
||||
// per-shard decrypted temps are shredded.
|
||||
func TestEncryptedSource_GoldenChain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
master := make([]byte, 32)
|
||||
if _, err := rand.Read(master); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
datadir, _, keyPEM := buildEncryptedDatadir(t, master)
|
||||
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
|
||||
workDir := t.TempDir()
|
||||
dest := t.TempDir()
|
||||
|
||||
if err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, walMode{}); err != nil {
|
||||
t.Fatalf("runEncrypted: %v", err)
|
||||
}
|
||||
|
||||
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen dest: %v", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// ---- Global shard: both orgs + the cert's signing key landed. ----
|
||||
org, err := store.GetOrganizationByName(ctx, dst, "hanzo")
|
||||
if err != nil || org == nil {
|
||||
t.Fatalf("org hanzo not migrated from global shard: %v", err)
|
||||
}
|
||||
if org.PasswordType != "argon2id" {
|
||||
t.Errorf("org.PasswordType = %q, want argon2id", org.PasswordType)
|
||||
}
|
||||
if acme, err := store.GetOrganizationByName(ctx, dst, "acme"); err != nil || acme == nil {
|
||||
t.Fatalf("org acme not migrated from global shard: %v", err)
|
||||
}
|
||||
cert, err := store.GetCert(ctx, dst, "admin", "cert-hanzo")
|
||||
if err != nil || cert == nil {
|
||||
t.Fatalf("cert not migrated from global shard: %v", err)
|
||||
}
|
||||
if cert.PrivateKey != keyPEM {
|
||||
t.Fatalf("cert.PrivateKey NOT verbatim through encrypt→decrypt→migrate:\n got %q\nwant %q", cert.PrivateKey, keyPEM)
|
||||
}
|
||||
|
||||
// ---- Org shard hanzo: THE non-negotiable golden argon2id verify. ----
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("user hanzo/z not migrated from org shard: %v", err)
|
||||
}
|
||||
if u.PasswordHash != goldenDigest {
|
||||
t.Fatalf("user.PasswordHash NOT verbatim:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
|
||||
}
|
||||
typ := cred.Resolve(u.PasswordType, org.PasswordType)
|
||||
if !cred.Verify(typ, goldenPassword, u.PasswordHash) {
|
||||
t.Fatal("cred.Verify REJECTED the argon2id hash decrypted from the encrypted shard — login would fail at cutover")
|
||||
}
|
||||
if cred.Verify(typ, "wrong-password", u.PasswordHash) {
|
||||
t.Fatal("cred.Verify ACCEPTED a wrong password against the decrypted hash")
|
||||
}
|
||||
|
||||
// ---- Org shard acme MERGED into the same store; its user verifies too. ----
|
||||
root, err := store.GetUserByName(ctx, dst, "acme", "root")
|
||||
if err != nil || root == nil {
|
||||
t.Fatalf("user acme/root not migrated (shards did not merge): %v", err)
|
||||
}
|
||||
if !cred.Verify(cred.Resolve(root.PasswordType, "argon2id"), goldenPassword, root.PasswordHash) {
|
||||
t.Fatal("cred.Verify REJECTED the acme user's decrypted hash")
|
||||
}
|
||||
|
||||
// ---- Every decrypted temp was shredded: the work-dir is empty. ----
|
||||
left, err := os.ReadDir(workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) != 0 {
|
||||
t.Errorf("work-dir not clean after run — %d decrypted temp(s) left: %v", len(left), left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedSource_WrongMasterFailsLoud proves a wrong master key fails at
|
||||
// UnwrapDEK and NEVER proceeds to write garbage: the run errors and the dest
|
||||
// store is left empty.
|
||||
func TestEncryptedSource_WrongMasterFailsLoud(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
master := make([]byte, 32)
|
||||
if _, err := rand.Read(master); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
datadir, _, _ := buildEncryptedDatadir(t, master)
|
||||
|
||||
// A different, valid-shaped key — must not unwrap any shard's DEK.
|
||||
wrong := make([]byte, 32)
|
||||
wrong[0] = master[0] ^ 0xff
|
||||
copy(wrong[1:], master[1:])
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(wrong))
|
||||
dest := t.TempDir()
|
||||
|
||||
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", t.TempDir(), dest, false, nil, walMode{})
|
||||
if err == nil {
|
||||
t.Fatal("wrong master key must fail loudly, got nil error")
|
||||
}
|
||||
|
||||
// Nothing was written: the dest store has no users.
|
||||
dst, oerr := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
|
||||
if oerr != nil {
|
||||
t.Fatalf("reopen dest: %v", oerr)
|
||||
}
|
||||
defer dst.Close()
|
||||
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u != nil {
|
||||
t.Fatal("a wrong master key still wrote a user — must abort before any write")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedSource_BadMasterKeyEnv rejects a malformed key env WITHOUT ever
|
||||
// echoing the value.
|
||||
func TestEncryptedSource_BadMasterKeyEnv(t *testing.T) {
|
||||
for _, tc := range []struct{ name, val string }{
|
||||
{"empty", ""},
|
||||
{"not-hex", "zznothex"},
|
||||
{"short", "00112233"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", tc.val)
|
||||
if _, err := loadMasterKey("MIGRATE_V1_TEST_MASTER_KEY"); err == nil {
|
||||
t.Fatalf("%s master key must be rejected", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// This file adds the ENCRYPTED, SHARDED source path to migrate-v1. Production
|
||||
// IAM stores its identity data as SQLCipher-encrypted SQLite with envelope
|
||||
// encryption, sharded:
|
||||
//
|
||||
// <datadir>/iam.db GLOBAL shard: certs, applications, organizations
|
||||
// <datadir>/iam.db.dek wrapped-DEK sidecar for the global shard
|
||||
// <datadir>/orgs/<slug>/iam.db PER-ORG shard: that org's users/apps/...
|
||||
// <datadir>/orgs/<slug>/iam.db.dek wrapped-DEK sidecar for the org shard
|
||||
//
|
||||
// Each shard is decrypted to a 0600 temp with the SAME pure-Go recipe:
|
||||
//
|
||||
// 1. kek = sqlite.DeriveKey(master, principalType, principalID) (HKDF-SHA256)
|
||||
// 2. blob = read <db>.dek (wrapped DEK)
|
||||
// 3. dek = sqlite.UnwrapDEK(kek, blob, sqlite.PrincipalAAD(...)) (AES-256-GCM)
|
||||
// 4. sqlcipher.DecryptFile(tmp, db, sqlcipher.RawKey(dek), {}) (page codec)
|
||||
//
|
||||
// then fed to the EXISTING Migrate engine (opened read-only with modernc) and
|
||||
// SHREDDED. Shards merge into one --dest store because Migrate upserts by
|
||||
// natural key (owner/name) and is idempotent.
|
||||
//
|
||||
// DRIVER-COLLISION RESOLUTION (why this is one binary, no os/exec helper):
|
||||
// DeriveKey, UnwrapDEK, PrincipalAAD and the PrincipalGlobal/PrincipalOrg consts
|
||||
// are PURE functions in the ROOT github.com/hanzoai/sqlite package — the SAME
|
||||
// package migrate.go already imports (blank) for the "sqlite" database/sql
|
||||
// driver. Promoting that dependency to a named import here registers NO new
|
||||
// driver: under CGO_ENABLED=0 hanzoai/sqlite's !cgo backend and orm's store both
|
||||
// route through modernc's SINGLE sql.Register("sqlite", …), so there is exactly
|
||||
// one registrant and no "Register called twice" panic. github.com/hanzoai/sqlcipher
|
||||
// registers no database/sql driver at all (it is a pure page/file codec). So the
|
||||
// decrypt and the plaintext read coexist in one process with zero collision, and
|
||||
// the crypto stays in hanzoai/sqlite — never re-implemented here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/hanzoai/sqlcipher"
|
||||
hsqlite "github.com/hanzoai/sqlite"
|
||||
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// globalPrincipalID is the KEK principal id for the cross-org GLOBAL shard
|
||||
// (certs, applications, organizations). It MUST byte-match the fork so a DEK
|
||||
// wrapped under the production master key unwraps here: the Casdoor fork pins it
|
||||
// as a const in object/ormer.go — `const globalPrincipalID = "iam"`. A drift
|
||||
// here silently fails UnwrapDEK on the global shard, so it is duplicated as a
|
||||
// deliberate, documented constant rather than imported (the fork is not a dep).
|
||||
const globalPrincipalID = "iam"
|
||||
|
||||
// encShard is one encrypted SQLite shard to decrypt and migrate: its on-disk
|
||||
// path (and implied `<path>.dek` sidecar) plus the (principalType, principalID)
|
||||
// whose KEK wraps its DEK.
|
||||
type encShard struct {
|
||||
label string // human label for the per-shard report ("global", "org:<slug>")
|
||||
path string // the encrypted db; its wrapped-DEK sidecar is path + ".dek"
|
||||
pt hsqlite.PrincipalType
|
||||
pid string
|
||||
}
|
||||
|
||||
// runEncrypted migrates the sharded ENCRYPTED source at datadir into the clean
|
||||
// --dest store: global shard first (orgs/certs/apps), then every orgs/<slug>
|
||||
// shard (users), each decrypted to a shredded temp and merged by upsert. It is
|
||||
// the encrypted-source sibling of run() and shares the exact same Migrate engine
|
||||
// and store-open path, so an encrypted cutover and a plaintext one produce a
|
||||
// byte-identical clean store.
|
||||
//
|
||||
// The wal mode selects HOW each shard is turned into a plaintext temp: the
|
||||
// default DecryptFile path decrypts only the CHECKPOINTED main-db image (fast,
|
||||
// pure-Go, but blind to rows still in the shard's uncheckpointed -wal), while
|
||||
// wal.enabled drives the C sqlcipher binary to checkpoint each shard's WAL into
|
||||
// the plaintext copy first — the COMPLETE extraction a real cutover needs.
|
||||
func runEncrypted(ctx context.Context, datadir, keyEnv, workDir, dest string, dryRun bool, only []string, wal walMode) error {
|
||||
// Fail before touching any shard if --wal-inclusive was requested but the C
|
||||
// sqlcipher binary is missing — never silently fall back to the checkpointed
|
||||
// (WAL-blind) path.
|
||||
if err := preflightWAL(wal); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
master, err := loadMasterKey(keyEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zero(master) // scrub the master key from memory when done
|
||||
|
||||
shards, err := discoverShards(datadir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The default (checkpointed) path reads ONLY each shard's checkpointed main db
|
||||
// and is blind to rows still in an uncheckpointed -wal. Running it against a
|
||||
// shard that carries a non-empty -wal would SILENTLY drop those (most-recent)
|
||||
// rows and still report success — the exact silent-data-loss default RED
|
||||
// flagged. Refuse, fail-closed, unless the operator opts into capturing the WAL
|
||||
// (--wal-inclusive) or explicitly into dropping it (--ignore-wal). Checked
|
||||
// before the dest store is even opened, so a refusal writes nothing.
|
||||
if !wal.enabled {
|
||||
if err := guardCheckpointedWAL(shards, wal.ignoreWAL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dst, err := store.Open("sqlite", storePath(dest))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open clean store: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
extraction := "checkpointed MAIN db only (guarded: refuses a non-empty uncheckpointed -wal unless --ignore-wal)"
|
||||
switch {
|
||||
case wal.enabled:
|
||||
extraction = "WAL-INCLUSIVE — each shard's -wal is checkpointed into the plaintext copy via C sqlcipher before migrating"
|
||||
case wal.ignoreWAL:
|
||||
extraction = "checkpointed MAIN db only, --ignore-wal — any uncheckpointed -wal rows are INTENTIONALLY DROPPED"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout,
|
||||
"migrate-v1: encrypted source %q — %d shard(s); extraction: %s.\n",
|
||||
datadir, len(shards), extraction)
|
||||
|
||||
for _, sh := range shards {
|
||||
results, err := migrateEncryptedShard(ctx, sh, master, workDir, dst, dryRun, only, wal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shard %s (%s): %w", sh.label, sh.path, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "\n=== shard %s (%s) ===", sh.label, sh.path)
|
||||
printReport(os.Stdout, results, dryRun)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadMasterKey reads the 64-hex KMS master key from the NAMED env var and
|
||||
// decodes it to the 32 raw bytes DeriveKey expects. The key value is NEVER
|
||||
// echoed — not in an error, not in a log — only its length is ever reported.
|
||||
func loadMasterKey(env string) ([]byte, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(env))
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("master key: env %s is empty (expected 64 hex chars)", env)
|
||||
}
|
||||
key, err := hex.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("master key from %s: not valid hex", env) // never print the value
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("master key from %s: must be 32 bytes (64 hex chars), got %d", env, len(key))
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// discoverShards enumerates the encrypted shards in dependency order: the global
|
||||
// db first, then every orgs/<slug>/iam.db (slugs sorted for deterministic runs).
|
||||
// A missing global db is fatal (the layout root is wrong); a missing orgs/ dir is
|
||||
// fine (global-only datadir); an org dir without an iam.db is skipped, not fatal.
|
||||
func discoverShards(datadir string) ([]encShard, error) {
|
||||
global := filepath.Join(datadir, "iam.db")
|
||||
if _, err := os.Stat(global); err != nil {
|
||||
return nil, fmt.Errorf("global shard %s: %w", global, err)
|
||||
}
|
||||
shards := []encShard{{label: "global", path: global, pt: hsqlite.PrincipalGlobal, pid: globalPrincipalID}}
|
||||
|
||||
orgsDir := filepath.Join(datadir, "orgs")
|
||||
entries, err := os.ReadDir(orgsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return shards, nil // global-only layout is valid
|
||||
}
|
||||
return nil, fmt.Errorf("read orgs dir %s: %w", orgsDir, err)
|
||||
}
|
||||
slugs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
slugs = append(slugs, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(slugs)
|
||||
for _, slug := range slugs {
|
||||
p := filepath.Join(orgsDir, slug, "iam.db")
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
continue // an org dir carrying no iam.db is not a shard
|
||||
}
|
||||
shards = append(shards, encShard{label: "org:" + slug, path: p, pt: hsqlite.PrincipalOrg, pid: slug})
|
||||
}
|
||||
return shards, nil
|
||||
}
|
||||
|
||||
// guardCheckpointedWAL is the fail-closed gate for the default (checkpointed)
|
||||
// extraction. That path reads only a shard's checkpointed main db, so any shard
|
||||
// carrying a non-empty uncheckpointed -wal would have those rows SILENTLY dropped.
|
||||
// It stats every shard's <path>-wal and, if any is non-empty, either aborts with an
|
||||
// actionable error (the default — never silently lose data) or, when the operator
|
||||
// passed --ignore-wal, proceeds after LOUDLY warning exactly which shards' rows are
|
||||
// being dropped. An absent or empty -wal is clean (nothing to lose).
|
||||
func guardCheckpointedWAL(shards []encShard, ignore bool) error {
|
||||
var dirty []string
|
||||
for _, sh := range shards {
|
||||
if fi, err := os.Stat(sh.path + "-wal"); err == nil && fi.Size() > 0 {
|
||||
dirty = append(dirty, fmt.Sprintf("%s (%s-wal: %d bytes)", sh.label, sh.path, fi.Size()))
|
||||
}
|
||||
}
|
||||
if len(dirty) == 0 {
|
||||
return nil
|
||||
}
|
||||
if ignore {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"migrate-v1: WARNING --ignore-wal: %d shard(s) carry a non-empty uncheckpointed -wal whose rows will NOT be migrated (intentionally dropped): %s\n",
|
||||
len(dirty), strings.Join(dirty, "; "))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%d shard(s) carry a non-empty uncheckpointed -wal that the default checkpointed path would SILENTLY DROP: %s; re-run with --wal-inclusive to migrate those rows, or --ignore-wal to intentionally drop them",
|
||||
len(dirty), strings.Join(dirty, "; "))
|
||||
}
|
||||
|
||||
// migrateEncryptedShard turns one shard into a shredded plaintext temp and runs
|
||||
// the Migrate engine over it. Which extraction is used depends on wal: the
|
||||
// default DecryptFile path (checkpointed main db only) or the WAL-inclusive C
|
||||
// sqlcipher path (checkpoints -wal first). Either way the plaintext temp holds
|
||||
// credential material, so it is shredded on EVERY path including error (deferred
|
||||
// immediately after creation), and both converge on the SAME read-only modernc
|
||||
// open + Migrate engine.
|
||||
func migrateEncryptedShard(ctx context.Context, sh encShard, master []byte, workDir string, dst orm.DB, dryRun bool, only []string, wal walMode) ([]*EntityResult, error) {
|
||||
var (
|
||||
srcPath string
|
||||
cleanup func()
|
||||
)
|
||||
if wal.enabled {
|
||||
plainPath, tmpDir, err := checkpointShardToPlaintext(sh, master, workDir, wal.bin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srcPath, cleanup = plainPath, func() { shredDir(tmpDir) }
|
||||
} else {
|
||||
tmp, err := decryptToTemp(sh, master, workDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srcPath, cleanup = tmp, func() { shred(tmp) }
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
src, err := openLegacy(srcPath) // read-only modernc open — the SAME reader the plaintext path uses
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
if err := src.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("open decrypted shard read-only: %w", err)
|
||||
}
|
||||
return Migrate(ctx, src, dst, only, options{dryRun: dryRun})
|
||||
}
|
||||
|
||||
// deriveDEK runs the pure-Go envelope recipe that recovers a shard's 32-byte
|
||||
// SQLCipher DEK from the KMS master key: derive the shard's KEK, read its wrapped
|
||||
// -DEK sidecar, unwrap it under the principal-binding AAD. It is the SINGLE place
|
||||
// the DEK is computed — BOTH the checkpointed DecryptFile path and the
|
||||
// WAL-inclusive C-sqlcipher path call it, so the key is never re-derived two ways
|
||||
// that could drift. A WRONG master key fails LOUDLY here at UnwrapDEK (the
|
||||
// AES-256-GCM auth tag rejects a KEK derived from garbage). The caller owns the
|
||||
// returned DEK and MUST zero it; deriveDEK never logs the key or the DEK.
|
||||
func deriveDEK(sh encShard, master []byte) ([]byte, error) {
|
||||
kek, err := hsqlite.DeriveKey(master, sh.pt, sh.pid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive KEK: %w", err)
|
||||
}
|
||||
defer zero(kek)
|
||||
|
||||
wrapped, err := os.ReadFile(sh.path + ".dek")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read wrapped-DEK sidecar %s.dek: %w", sh.path, err)
|
||||
}
|
||||
dek, err := hsqlite.UnwrapDEK(kek, wrapped, hsqlite.PrincipalAAD(sh.pt, sh.pid))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unwrap DEK (wrong master key, or corrupt/foreign sidecar): %w", err)
|
||||
}
|
||||
return dek, nil
|
||||
}
|
||||
|
||||
// decryptToTemp runs the (checkpointed) envelope-decrypt recipe for one shard and
|
||||
// writes the plaintext SQLite bytes to a fresh 0600 temp under workDir (OS temp
|
||||
// when empty), returning the temp path. A wrong DEK or a corrupt page fails at
|
||||
// DecryptFile (per-page HMAC → sqlcipher.ErrKey). It never returns a temp on
|
||||
// error, and never logs the key or the DEK. NOTE: this reads only the shard's
|
||||
// CHECKPOINTED main db — rows in its uncheckpointed -wal are invisible; the
|
||||
// WAL-inclusive path (checkpointShardToPlaintext) is the complete extraction.
|
||||
func decryptToTemp(sh encShard, master []byte, workDir string) (string, error) {
|
||||
dek, err := deriveDEK(sh, master)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer zero(dek)
|
||||
|
||||
in, err := os.Open(sh.path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.CreateTemp(workDir, "iam-migrate-*.db") // 0600
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create work temp: %w", err)
|
||||
}
|
||||
tmp := out.Name()
|
||||
// sqlcipher.Params{} = SQLCipher 4 defaults (4096-byte pages), which the CGO
|
||||
// production backend writes. DecryptFile reads the salt from page 1 of the
|
||||
// source and emits a plaintext db any SQLite build (here: modernc) can open.
|
||||
if err := sqlcipher.DecryptFile(out, in, sqlcipher.RawKey(dek), sqlcipher.Params{}); err != nil {
|
||||
out.Close()
|
||||
shred(tmp)
|
||||
return "", fmt.Errorf("decrypt shard: %w", err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
shred(tmp)
|
||||
return "", err
|
||||
}
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
// zero scrubs key material from a byte slice.
|
||||
func zero(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// shred overwrites a decrypted temp with zeros and removes it. The temp holds
|
||||
// plaintext credential material (password digests, signing keys), so it must not
|
||||
// survive the run. Best-effort by design: a stat/open failure still attempts the
|
||||
// remove, so a shred never blocks the migration.
|
||||
func shred(path string) {
|
||||
if fi, err := os.Stat(path); err == nil && fi.Size() > 0 {
|
||||
if f, err := os.OpenFile(path, os.O_WRONLY, 0o600); err == nil {
|
||||
buf := make([]byte, 32*1024)
|
||||
remaining := fi.Size()
|
||||
for remaining > 0 {
|
||||
n := int64(len(buf))
|
||||
if n > remaining {
|
||||
n = remaining
|
||||
}
|
||||
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||
break
|
||||
}
|
||||
remaining -= n
|
||||
}
|
||||
_ = f.Sync()
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Command migrate-v1 is the Phase-5 cutover migrator: it reads the legacy
|
||||
// Casdoor-fork identity store (a SQLite iam.db) and writes every identity record
|
||||
// into the clean-room IAM v2 store, PRESERVING credentials and signing keys
|
||||
// byte-for-byte. A wrong password hash locks a user out; a wrong signing cert
|
||||
// breaks every live token and the JWKS — so correctness, not cleverness, is the
|
||||
// whole job.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// migrate-v1 --src /path/to/legacy/iam.db --dest /path/to/clean/data-dir \
|
||||
// [--dry-run] [--only users,orgs,apps,certs,providers,memberships]
|
||||
//
|
||||
// The source is opened READ-ONLY. The destination is opened through the exact
|
||||
// store-open path the server uses (store.Open), so the migrated store is
|
||||
// byte-for-byte the store the server will serve. Every entity is UPSERTed by its
|
||||
// natural key (owner/name): the tool is idempotent — re-running is a no-op, and
|
||||
// --dry-run reports counts + a redacted sample without writing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
|
||||
_ "github.com/hanzoai/iam/internal/schema" // registers the v2 entity kinds
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
fs := flag.NewFlagSet("migrate-v1", flag.ContinueOnError)
|
||||
var (
|
||||
srcPath = fs.String("src", "", "path to a PLAINTEXT legacy Casdoor SQLite iam.db (opened read-only); mutually exclusive with --src-datadir")
|
||||
srcDatadir = fs.String("src-datadir", "", "root of the ENCRYPTED sharded source (<dir>/iam.db + <dir>/orgs/*/iam.db, each with a .dek sidecar); mutually exclusive with --src")
|
||||
masterKeyEnv = fs.String("src-master-key-env", "IAM_KMS_MASTER_KEY", "NAME of the env var holding the 64-hex KMS master key (read for --src-datadir; never taken as an arg or logged)")
|
||||
workDir = fs.String("work-dir", "", "directory for decrypted temp files (default: OS temp); each is created 0600 and shredded after use")
|
||||
dest = fs.String("dest", "", "clean IAM v2 data-dir (the store is <dest>/iam2.db) or a .db path")
|
||||
dryRun = fs.Bool("dry-run", false, "count + sample per entity without writing")
|
||||
only = fs.String("only", "", "comma list of entities: users,orgs,apps,certs,providers,memberships,roles,permissions (default all)")
|
||||
walInclusive = fs.Bool("wal-inclusive", false, "encrypted source only: checkpoint each shard's uncheckpointed -wal into the plaintext copy via the C sqlcipher binary before migrating (COMPLETE extraction; default reads only the checkpointed main db and misses WAL rows)")
|
||||
ignoreWAL = fs.Bool("ignore-wal", false, "encrypted source only: in the DEFAULT (checkpointed) path, proceed even when a shard carries a non-empty uncheckpointed -wal, INTENTIONALLY dropping those rows (mutually exclusive with --wal-inclusive; without either, a non-empty -wal is a hard error)")
|
||||
sqlcipherBin = fs.String("sqlcipher-bin", "sqlcipher", "path to (or name on PATH of) the C sqlcipher binary used by --wal-inclusive")
|
||||
)
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
switch {
|
||||
case *srcPath != "" && *srcDatadir != "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: --src and --src-datadir are mutually exclusive")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
case *srcPath == "" && *srcDatadir == "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: one of --src (plaintext) or --src-datadir (encrypted sharded) is required")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
case *dest == "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: --dest is required")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
case *walInclusive && *srcDatadir == "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: --wal-inclusive applies only to the encrypted sharded source (--src-datadir)")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
case *ignoreWAL && *srcDatadir == "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: --ignore-wal applies only to the encrypted sharded source (--src-datadir)")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
case *walInclusive && *ignoreWAL:
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: --wal-inclusive (capture the WAL) and --ignore-wal (drop the WAL) are mutually exclusive")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var err error
|
||||
if *srcDatadir != "" {
|
||||
wal := walMode{enabled: *walInclusive, ignoreWAL: *ignoreWAL, bin: *sqlcipherBin}
|
||||
err = runEncrypted(ctx, *srcDatadir, *masterKeyEnv, *workDir, *dest, *dryRun, splitOnly(*only), wal)
|
||||
} else {
|
||||
err = run(ctx, *srcPath, *dest, *dryRun, splitOnly(*only))
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-v1: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, srcPath, dest string, dryRun bool, only []string) error {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return fmt.Errorf("source iam.db: %w", err)
|
||||
}
|
||||
|
||||
src, err := openLegacy(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
if err := src.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("open legacy iam.db read-only: %w", err)
|
||||
}
|
||||
|
||||
dst, err := store.Open("sqlite", storePath(dest))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open clean store: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
results, err := Migrate(ctx, src, dst, only, options{dryRun: dryRun})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printReport(os.Stdout, results, dryRun)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openLegacy opens the legacy iam.db strictly read-only via a file: URI, so the
|
||||
// migrator can never mutate the source (and can run against a live-ish copy).
|
||||
func openLegacy(path string) (*sql.DB, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+abs+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open legacy iam.db: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// storePath maps the --dest data-dir to the SQLite file the server uses
|
||||
// (<dest>/iam2.db), or takes dest verbatim when it already names a .db file.
|
||||
func storePath(dest string) string {
|
||||
if strings.HasSuffix(dest, ".db") {
|
||||
return dest
|
||||
}
|
||||
return filepath.Join(dest, "iam2.db")
|
||||
}
|
||||
|
||||
// splitOnly parses the comma-separated --only value.
|
||||
func splitOnly(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// printReport writes the per-entity summary: rows read vs written, skips with
|
||||
// reasons, and legacy columns that had no clean-schema home (the pre-flip gap
|
||||
// list). In --dry-run it also prints the redacted sample per entity.
|
||||
func printReport(w *os.File, results []*EntityResult, dryRun bool) {
|
||||
mode := "MIGRATE"
|
||||
if dryRun {
|
||||
mode = "DRY-RUN (no writes)"
|
||||
}
|
||||
fmt.Fprintf(w, "\niam2 migrate-v1 — %s\n\n", mode)
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "entity\tlegacy_table\tread\tcreated\tupdated\tunchanged\tskipped")
|
||||
var tr, tc, tu, tun, ts int
|
||||
for _, r := range results {
|
||||
table := r.Table
|
||||
if r.TableMissing {
|
||||
table = "(missing)"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%d\t%d\n",
|
||||
r.Entity, table, r.Read, r.Created, r.Updated, r.Unchanged, r.Skipped)
|
||||
tr, tc, tu, tun, ts = tr+r.Read, tc+r.Created, tu+r.Updated, tun+r.Unchanged, ts+r.Skipped
|
||||
}
|
||||
fmt.Fprintf(tw, "TOTAL\t\t%d\t%d\t%d\t%d\t%d\n", tr, tc, tu, tun, ts)
|
||||
tw.Flush()
|
||||
|
||||
for _, r := range results {
|
||||
if len(r.Reasons) > 0 {
|
||||
fmt.Fprintf(w, "\n%s notes:\n", r.Entity)
|
||||
for reason, n := range r.Reasons {
|
||||
fmt.Fprintf(w, " %-28s %d\n", reason, n)
|
||||
}
|
||||
}
|
||||
if len(r.UnmappedCols) > 0 {
|
||||
fmt.Fprintf(w, "\n%s legacy columns with no clean-schema field (not migrated):\n %s\n",
|
||||
r.Entity, strings.Join(r.UnmappedCols, ", "))
|
||||
}
|
||||
if dryRun && r.Sample != "" {
|
||||
fmt.Fprintf(w, "\n%s sample (redacted):\n%s\n", r.Entity, r.Sample)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// MEMBERSHIP MIGRATION. migrate-v1 carried no memberships, so a multi-org user's
|
||||
// `orgs` claim collapsed to the home org alone. Casdoor's `membership` table
|
||||
// (owner,name,user,org,role) migrates verbatim; store.MemberOrgRefs then
|
||||
// reproduces the full tenancy set (home ∪ explicit).
|
||||
|
||||
// newLegacyMembershipDB builds a legacy iam.db with the organizations, the user,
|
||||
// and a `membership` table binding hanzo/z into hanzo, lux, zoo, and pars.
|
||||
func newLegacyMembershipDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "iam.db")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE organization (owner text, name text, created_time text, display_name text)`,
|
||||
`CREATE TABLE user (owner text, name text, created_time text, id text, email text, is_admin integer)`,
|
||||
`CREATE TABLE membership (owner text, name text, created_time text, user text, org text, role text)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("create table: %v\n%s", err, s)
|
||||
}
|
||||
}
|
||||
exec := func(q string, args ...any) {
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
for _, org := range []string{"hanzo", "lux", "zoo", "pars"} {
|
||||
exec(`INSERT INTO organization VALUES(?,?,?,?)`, "admin", org, "2020-01-02T03:04:05Z", org)
|
||||
}
|
||||
exec(`INSERT INTO user VALUES(?,?,?,?,?,?)`,
|
||||
"hanzo", "z", "2020-01-02T03:04:05Z", "uuid-0001", "z@hanzo.ai", 1)
|
||||
// Four casdoor membership rows: z acts in hanzo (home), lux, zoo, pars.
|
||||
rows := []struct{ org, role string }{
|
||||
{"hanzo", "admin"}, {"lux", "member"}, {"zoo", "member"}, {"pars", "owner"},
|
||||
}
|
||||
for _, r := range rows {
|
||||
exec(`INSERT INTO membership VALUES(?,?,?,?,?,?)`,
|
||||
"admin", "z|"+r.org, "2020-01-02T03:04:05Z", "hanzo/z", r.org, r.role)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestMigrate_Memberships(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srcPath := newLegacyMembershipDB(t)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := store.Open("sqlite", filepath.Join(t.TempDir(), "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open dest: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { dst.Close() })
|
||||
|
||||
// Migrate in dependency order: orgs, users, then memberships.
|
||||
results, err := Migrate(ctx, src, dst, []string{"orgs", "users", "memberships"}, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
byEntity := indexResults(results)
|
||||
|
||||
// N casdoor rows migrate to N clean rows, zero skipped.
|
||||
m := byEntity["memberships"]
|
||||
if m == nil {
|
||||
t.Fatal("memberships entity was not migrated")
|
||||
}
|
||||
if m.Read != 4 || m.Created != 4 || m.Skipped != 0 {
|
||||
t.Fatalf("membership counts = read %d/created %d/skipped %d, want 4/4/0", m.Read, m.Created, m.Skipped)
|
||||
}
|
||||
|
||||
// The migrated relation reproduces all four orgs through MemberOrgRefs
|
||||
// (home ∪ explicit).
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("user z not migrated: %v", err)
|
||||
}
|
||||
refs := store.MemberOrgRefs(ctx, dst, u)
|
||||
got := make([]string, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
got = append(got, r.Org)
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := []string{"hanzo", "lux", "pars", "zoo"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("orgs = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("orgs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_Memberships_Idempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src, err := sql.Open("sqlite", newLegacyMembershipDB(t))
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := store.Open("sqlite", filepath.Join(t.TempDir(), "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open dest: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { dst.Close() })
|
||||
|
||||
only := []string{"orgs", "users", "memberships"}
|
||||
if _, err := Migrate(ctx, src, dst, only, options{}); err != nil {
|
||||
t.Fatalf("first pass: %v", err)
|
||||
}
|
||||
results, err := Migrate(ctx, src, dst, only, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("second pass: %v", err)
|
||||
}
|
||||
m := indexResults(results)["memberships"]
|
||||
if m.Created != 0 || m.Updated != 0 || m.Read != m.Unchanged {
|
||||
t.Errorf("re-run not idempotent: created=%d updated=%d read=%d unchanged=%d",
|
||||
m.Created, m.Updated, m.Read, m.Unchanged)
|
||||
}
|
||||
}
|
||||
@@ -1,635 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
// Registers the "sqlite" database/sql driver name — the SAME package orm's
|
||||
// store routes through, so importing it here is a no-op second reference,
|
||||
// never a second sql.Register (which would panic). Under CGO_ENABLED=0 the
|
||||
// registration is modernc's; the source iam.db is opened read-only with it.
|
||||
_ "github.com/hanzoai/sqlite"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// EntityResult is the per-entity outcome of a migration pass.
|
||||
type EntityResult struct {
|
||||
Entity string // canonical entity name (e.g. "users")
|
||||
Table string // resolved legacy table name (empty if missing)
|
||||
TableMissing bool // the legacy DB has no table for this entity
|
||||
Read int // rows read from the legacy table
|
||||
Created int // rows created in the clean store
|
||||
Updated int // rows whose clean row differed and was overwritten
|
||||
Unchanged int // rows already byte-identical (idempotent no-op)
|
||||
Skipped int // rows skipped (see Reasons)
|
||||
Reasons map[string]int // reason -> count (skips, coercions, defaults)
|
||||
UnmappedCols []string // legacy columns with no clean-schema field
|
||||
Sample string // a redacted sample row (dry-run only)
|
||||
}
|
||||
|
||||
// options controls a migration pass.
|
||||
type options struct {
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// entitySpec binds a clean entity type to its legacy table(s) and the two
|
||||
// name-mismatch escape hatches: colAliases (a clean field fed by a differently
|
||||
// named legacy column) and sensitive (fields masked in any printed sample).
|
||||
// selectors are the --only names that pick this spec.
|
||||
type entitySpec struct {
|
||||
name string
|
||||
selectors []string
|
||||
tables []string
|
||||
colAliases map[string][]string
|
||||
sensitive map[string]bool
|
||||
run func(context.Context, *sql.DB, orm.DB, entitySpec, options) (*EntityResult, error)
|
||||
}
|
||||
|
||||
// specs is the ordered entity registry. Order is dependency order:
|
||||
// organizations own everything; certs sign the tokens applications mint;
|
||||
// applications reference certs; providers are linked by applications; users
|
||||
// live under organizations; roles/permissions reference users. A downstream
|
||||
// entity is never migrated before the entity it points at.
|
||||
func specs() []entitySpec {
|
||||
return []entitySpec{
|
||||
{
|
||||
name: "organizations",
|
||||
selectors: []string{"organizations", "organization", "orgs", "org"},
|
||||
tables: []string{"organization", "organizations"},
|
||||
sensitive: set("passwordSalt", "masterPassword", "defaultPassword",
|
||||
"masterVerificationCode", "passwordObfuscatorKey", "kerberosKeytab"),
|
||||
run: runner[schema.Organization](),
|
||||
},
|
||||
{
|
||||
name: "certs",
|
||||
selectors: []string{"certs", "cert", "certificates"},
|
||||
tables: []string{"cert", "certs"},
|
||||
// PrivateKey + AccessSecret are the JWKS signing material and ACME
|
||||
// credential — copied verbatim, never printed.
|
||||
sensitive: set("privateKey", "accessSecret"),
|
||||
run: runner[schema.Cert](),
|
||||
},
|
||||
{
|
||||
name: "applications",
|
||||
selectors: []string{"applications", "application", "apps", "app"},
|
||||
tables: []string{"application", "applications"},
|
||||
sensitive: set("clientSecret"),
|
||||
run: runner[schema.Application](),
|
||||
},
|
||||
{
|
||||
name: "providers",
|
||||
selectors: []string{"providers", "provider"},
|
||||
tables: []string{"provider", "providers"},
|
||||
sensitive: set("clientSecret", "clientSecret2"),
|
||||
run: runner[schema.Provider](),
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
selectors: []string{"users", "user"},
|
||||
tables: []string{"user", "users"},
|
||||
// THE credential-critical mapping: Casdoor stores the password DIGEST
|
||||
// in a column literally named `password`; the clean schema renamed the
|
||||
// field to PasswordHash (json "passwordHash"). Normalization can't bridge
|
||||
// that rename, so it is declared explicitly. Miss this and every user's
|
||||
// hash is dropped — 100% login failure at cutover.
|
||||
colAliases: map[string][]string{"passwordHash": {"password"}},
|
||||
sensitive: set("passwordHash", "passwordSalt", "accessSecret",
|
||||
"accessSecretHash", "accessToken", "originalToken",
|
||||
"originalRefreshToken", "totpSecret", "recoveryCodes"),
|
||||
run: runner[schema.User](),
|
||||
},
|
||||
{
|
||||
// Memberships carry the (User × Org × Role) tenancy relation — the source
|
||||
// of a user's multi-org `orgs` claim. Without them z's orgs collapses from
|
||||
// [hanzo,lux,zoo,pars] to [hanzo] (the home org alone). Casdoor's
|
||||
// `membership` table has the identical (owner,name,user,org,role) shape as
|
||||
// schema.Membership, so the generic engine carries it verbatim, keyed by the
|
||||
// (owner,name) natural key — idempotent on re-run. Ordered AFTER users and
|
||||
// organizations, which it references.
|
||||
name: "memberships",
|
||||
selectors: []string{"memberships", "membership"},
|
||||
tables: []string{"membership", "memberships"},
|
||||
run: runner[schema.Membership](),
|
||||
},
|
||||
{
|
||||
name: "roles",
|
||||
selectors: []string{"roles", "role"},
|
||||
tables: []string{"role", "roles"},
|
||||
run: runner[schema.Role](),
|
||||
},
|
||||
{
|
||||
name: "permissions",
|
||||
selectors: []string{"permissions", "permission", "perms"},
|
||||
tables: []string{"permission", "permissions"},
|
||||
run: runner[schema.Permission](),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runner binds a clean entity type T to the generic engine.
|
||||
func runner[T any]() func(context.Context, *sql.DB, orm.DB, entitySpec, options) (*EntityResult, error) {
|
||||
return func(ctx context.Context, src *sql.DB, dst orm.DB, spec entitySpec, opt options) (*EntityResult, error) {
|
||||
return migrateEntity[T](ctx, src, dst, spec, opt)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate runs the selected entities (empty only == all) against dst in
|
||||
// dependency order. It is the pure engine: callers open src/dst and print the
|
||||
// results, so it is directly testable without touching the filesystem.
|
||||
func Migrate(ctx context.Context, src *sql.DB, dst orm.DB, only []string, opt options) ([]*EntityResult, error) {
|
||||
chosen, err := selectSpecs(only)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*EntityResult, 0, len(chosen))
|
||||
for _, spec := range chosen {
|
||||
res, err := spec.run(ctx, src, dst, spec, opt)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("migrate %s: %w", spec.name, err)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// selectSpecs resolves the --only selectors to specs (in registry order). An
|
||||
// unrecognized selector is an error — a typo must never silently skip an entity.
|
||||
func selectSpecs(only []string) ([]entitySpec, error) {
|
||||
all := specs()
|
||||
if len(only) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, o := range only {
|
||||
o = strings.ToLower(strings.TrimSpace(o))
|
||||
if o != "" {
|
||||
want[o] = true
|
||||
}
|
||||
}
|
||||
matched := map[string]bool{}
|
||||
var chosen []entitySpec
|
||||
for _, spec := range all {
|
||||
for _, sel := range spec.selectors {
|
||||
if want[sel] {
|
||||
chosen = append(chosen, spec)
|
||||
matched[sel] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for sel := range want {
|
||||
if !matched[sel] {
|
||||
return nil, fmt.Errorf("unknown --only entity %q (valid: users, orgs, apps, certs, providers, memberships, roles, permissions)", sel)
|
||||
}
|
||||
}
|
||||
return chosen, nil
|
||||
}
|
||||
|
||||
// migrateEntity reads every row of the legacy table for T and upserts it into
|
||||
// the clean store, mapping legacy columns to clean fields by normalized name
|
||||
// (plus the spec's explicit column aliases). Credential and key material is
|
||||
// copied verbatim — the row is reconstructed as JSON and unmarshaled into T, so
|
||||
// bytes never pass through a lossy typed conversion.
|
||||
func migrateEntity[T any](ctx context.Context, src *sql.DB, dst orm.DB, spec entitySpec, opt options) (*EntityResult, error) {
|
||||
res := &EntityResult{Entity: spec.name, Reasons: map[string]int{}}
|
||||
|
||||
table, err := resolveTable(ctx, src, spec.tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if table == "" {
|
||||
res.TableMissing = true
|
||||
return res, nil
|
||||
}
|
||||
res.Table = table
|
||||
|
||||
cols, err := tableColumns(ctx, src, table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read columns of %q: %w", table, err)
|
||||
}
|
||||
fields := entityFields[T]()
|
||||
matched, consumed := matchColumns(fields, cols, spec.colAliases)
|
||||
for _, c := range cols {
|
||||
if !consumed[c] {
|
||||
res.UnmappedCols = append(res.UnmappedCols, c)
|
||||
}
|
||||
}
|
||||
if !hasField(matched, "name") {
|
||||
return nil, fmt.Errorf("legacy table %q has no column mapping to 'name' — cannot key rows", table)
|
||||
}
|
||||
|
||||
quoted := make([]string, len(matched))
|
||||
for i, f := range matched {
|
||||
quoted[i] = quoteIdent(f.column)
|
||||
}
|
||||
query := "SELECT " + strings.Join(quoted, ", ") + " FROM " + quoteIdent(table)
|
||||
rows, err := src.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select %q: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
vals := make([]sql.NullString, len(matched))
|
||||
dest := make([]any, len(matched))
|
||||
for i := range vals {
|
||||
dest[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
return nil, fmt.Errorf("scan %q: %w", table, err)
|
||||
}
|
||||
res.Read++
|
||||
|
||||
row := make(map[string]json.RawMessage, len(matched))
|
||||
for i, f := range matched {
|
||||
raw, ok := rawForField(f, vals[i])
|
||||
if !ok {
|
||||
if f.isJSON && vals[i].Valid {
|
||||
if t := strings.TrimSpace(vals[i].String); t != "" && t != "null" && !json.Valid([]byte(t)) {
|
||||
res.Reasons["invalid_json:"+f.jsonName]++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
row[f.jsonName] = raw
|
||||
}
|
||||
|
||||
owner := jsonUnquote(row["owner"])
|
||||
name := jsonUnquote(row["name"])
|
||||
if name == "" {
|
||||
res.Skipped++
|
||||
res.Reasons["empty_name"]++
|
||||
continue
|
||||
}
|
||||
if owner == "" {
|
||||
owner = "admin"
|
||||
row["owner"] = json.RawMessage(`"admin"`)
|
||||
res.Reasons["owner_defaulted_admin"]++
|
||||
}
|
||||
id := owner + "/" + name
|
||||
|
||||
blob, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal %s: %w", id, err)
|
||||
}
|
||||
|
||||
action, err := upsert[T](dst, id, blob, opt.dryRun)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert %s: %w", id, err)
|
||||
}
|
||||
switch action {
|
||||
case actionCreate:
|
||||
res.Created++
|
||||
case actionUpdate:
|
||||
res.Updated++
|
||||
case actionUnchanged:
|
||||
res.Unchanged++
|
||||
}
|
||||
if opt.dryRun && res.Sample == "" && action != actionUnchanged {
|
||||
res.Sample = redactSample(id, action, row, spec.sensitive)
|
||||
}
|
||||
}
|
||||
return res, rows.Err()
|
||||
}
|
||||
|
||||
const (
|
||||
actionCreate = "create"
|
||||
actionUpdate = "update"
|
||||
actionUnchanged = "unchanged"
|
||||
)
|
||||
|
||||
// upsert creates a row when absent, overwrites it when the clean row differs,
|
||||
// and is a true no-op when it already matches (idempotent re-run). In dry-run it
|
||||
// resolves the action without ever writing. The storage KEY is the (owner/name)
|
||||
// natural key; the legacy per-row UUID is carried as a DOMAIN field (schema.User.Id,
|
||||
// via the normal column mapping) — it is the OIDC `sub` the clean iam now mints for
|
||||
// continuity, but it never becomes the storage key.
|
||||
func upsert[T any](dst orm.DB, id string, blob []byte, dry bool) (string, error) {
|
||||
existing, err := orm.Get[T](dst, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
if dry {
|
||||
return actionCreate, nil
|
||||
}
|
||||
if _, _, err := orm.GetOrCreate[T](dst, id, apply[T](blob)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return actionCreate, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed, err := wouldChange(existing, blob)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !changed {
|
||||
return actionUnchanged, nil
|
||||
}
|
||||
if dry {
|
||||
return actionUpdate, nil
|
||||
}
|
||||
if _, err := orm.GetOrUpdate[T](dst, id, apply[T](blob)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return actionUpdate, nil
|
||||
}
|
||||
|
||||
// apply returns a mutator that overlays the legacy row's JSON onto a clean
|
||||
// entity. It sets only the fields present in blob (the orm.Model key/timestamps
|
||||
// are absent from blob, so they are never disturbed).
|
||||
func apply[T any](blob []byte) func(*T) {
|
||||
return func(d *T) { _ = json.Unmarshal(blob, d) }
|
||||
}
|
||||
|
||||
// wouldChange reports whether overlaying blob onto existing changes its
|
||||
// serialized form. Because blob carries only domain fields, the orm.Model
|
||||
// key/timestamps are held constant, so a re-run with identical source data is a
|
||||
// true no-op (no write, no UpdatedAt churn).
|
||||
func wouldChange[T any](existing *T, blob []byte) (bool, error) {
|
||||
before, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
clone := new(T)
|
||||
if err := json.Unmarshal(before, clone); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(blob, clone); err != nil {
|
||||
return false, err
|
||||
}
|
||||
after, err := json.Marshal(clone)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !bytes.Equal(before, after), nil
|
||||
}
|
||||
|
||||
// fieldSpec is one clean-schema field and the legacy column feeding it.
|
||||
type fieldSpec struct {
|
||||
jsonName string
|
||||
goName string
|
||||
kind reflect.Kind
|
||||
isJSON bool
|
||||
column string // resolved legacy column (set by matchColumns)
|
||||
}
|
||||
|
||||
// entityFields reflects T's stored fields. The embedded orm.Model[T] is skipped
|
||||
// (its promoted json keys id/createdAt/updatedAt/deleted are the storage key and
|
||||
// stamps, never sourced from the legacy row), as are json:"-" and unnamed
|
||||
// (json:"") fields.
|
||||
func entityFields[T any]() []fieldSpec {
|
||||
t := reflect.TypeFor[T]()
|
||||
var out []fieldSpec
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.Anonymous {
|
||||
continue // embedded orm.Model[T]
|
||||
}
|
||||
name := strings.Split(f.Tag.Get("json"), ",")[0]
|
||||
if name == "" || name == "-" {
|
||||
continue
|
||||
}
|
||||
out = append(out, fieldSpec{
|
||||
jsonName: name,
|
||||
goName: f.Name,
|
||||
kind: f.Type.Kind(),
|
||||
isJSON: isJSONKind(f.Type.Kind()),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isJSONKind reports whether a field serializes as JSON text in a legacy column
|
||||
// (slices, maps, structs, pointers) rather than a scalar.
|
||||
func isJSONKind(k reflect.Kind) bool {
|
||||
switch k {
|
||||
case reflect.Slice, reflect.Array, reflect.Map, reflect.Struct, reflect.Ptr, reflect.Interface:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchColumns pairs each clean field with a legacy column. It matches on a
|
||||
// normalized key (lowercase, underscores/dashes stripped) so xorm's snake_case
|
||||
// columns line up with camelCase json tags regardless of the exact mapper
|
||||
// ("created_time"⇔"createdTime", "git_hub"⇔"github"), then falls back to the
|
||||
// spec's explicit aliases. A column is consumed by at most one field.
|
||||
func matchColumns(fields []fieldSpec, cols []string, aliases map[string][]string) (matched []fieldSpec, consumed map[string]bool) {
|
||||
byNorm := make(map[string]string, len(cols))
|
||||
for _, c := range cols {
|
||||
byNorm[normalize(c)] = c
|
||||
}
|
||||
consumed = map[string]bool{}
|
||||
for _, f := range fields {
|
||||
cands := []string{normalize(f.jsonName), normalize(f.goName)}
|
||||
for _, a := range aliases[f.jsonName] {
|
||||
cands = append(cands, normalize(a))
|
||||
}
|
||||
for _, cand := range cands {
|
||||
col, ok := byNorm[cand]
|
||||
if !ok || consumed[col] {
|
||||
continue
|
||||
}
|
||||
f.column = col
|
||||
matched = append(matched, f)
|
||||
consumed[col] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return matched, consumed
|
||||
}
|
||||
|
||||
func hasField(matched []fieldSpec, jsonName string) bool {
|
||||
for _, f := range matched {
|
||||
if f.jsonName == jsonName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rawForField converts a scanned legacy value into the JSON encoding for the
|
||||
// clean field, or reports (nil,false) to omit it (NULL, empty, or the field's
|
||||
// zero value — omitempty makes absent and zero identical, keeping the blob
|
||||
// minimal and re-runs exactly idempotent). JSON-typed columns are passed through
|
||||
// verbatim when valid; scalars are re-encoded through their Go kind.
|
||||
func rawForField(f fieldSpec, ns sql.NullString) (json.RawMessage, bool) {
|
||||
if !ns.Valid {
|
||||
return nil, false
|
||||
}
|
||||
s := ns.String
|
||||
|
||||
if f.isJSON {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" || t == "null" || !json.Valid([]byte(t)) {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(t), true
|
||||
}
|
||||
|
||||
switch f.kind {
|
||||
case reflect.String:
|
||||
if s == "" {
|
||||
return nil, false
|
||||
}
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
case reflect.Bool:
|
||||
if s == "1" || strings.EqualFold(s, "true") {
|
||||
return json.RawMessage("true"), true
|
||||
}
|
||||
return nil, false // false is the zero value; omit
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
if fv, ferr := strconv.ParseFloat(strings.TrimSpace(s), 64); ferr == nil {
|
||||
n = int64(fv)
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(strconv.FormatInt(n, 10)), true
|
||||
case reflect.Float32, reflect.Float64:
|
||||
fv, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil || fv == 0 {
|
||||
return nil, false
|
||||
}
|
||||
b, err := json.Marshal(fv)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// normalize collapses a column or field name to its comparison key: lowercase
|
||||
// with underscores, dashes, and spaces removed.
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r == '_' || r == '-' || r == ' ' {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// jsonUnquote decodes a JSON string value, returning "" for anything else.
|
||||
func jsonUnquote(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveTable returns the first candidate table that exists in the legacy DB
|
||||
// (matched on the normalized name), or "" when none do.
|
||||
func resolveTable(ctx context.Context, db *sql.DB, candidates []string) (string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table'`)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
existing := map[string]string{}
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
existing[normalize(n)] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if actual, ok := existing[normalize(c)]; ok {
|
||||
return actual, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// tableColumns returns the column names of a legacy table via PRAGMA.
|
||||
func tableColumns(ctx context.Context, db *sql.DB, table string) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, "PRAGMA table_info("+quoteIdent(table)+")")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dflt sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
return cols, rows.Err()
|
||||
}
|
||||
|
||||
// quoteIdent double-quotes a SQLite identifier (table names come from
|
||||
// sqlite_master, not user input, but quoting keeps odd names safe).
|
||||
func quoteIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
// set builds a lookup set from keys.
|
||||
func set(keys ...string) map[string]bool {
|
||||
m := make(map[string]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
m[k] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// redactSample renders a one-row sample for --dry-run with secret fields masked
|
||||
// (a password digest or private key must never reach a log or a terminal).
|
||||
func redactSample(id, action string, row map[string]json.RawMessage, sensitive map[string]bool) string {
|
||||
view := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
if sensitive[k] {
|
||||
view[k] = fmt.Sprintf("<redacted:%d bytes>", len(v))
|
||||
continue
|
||||
}
|
||||
view[k] = json.RawMessage(v)
|
||||
}
|
||||
b, err := json.MarshalIndent(map[string]any{"id": id, "action": action, "fields": view}, " ", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/cred"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Golden argon2id digest, mirrored VERBATIM from internal/cred/golden_v1_test.go.
|
||||
// It was produced by v1's own Argon2idCredManager. Proving cred.Verify succeeds
|
||||
// against the MIGRATED hash — a hash that entered the clean store only through
|
||||
// this migrator — is the full end-to-end credential-parity assertion.
|
||||
const (
|
||||
goldenPassword = "golden-test-password-1"
|
||||
goldenDigest = "$argon2id$v=19$m=65536,t=1,p=2$oOen09XtFBqKnv2/K4q5mQ$iZKRwt09CdXDXr4E1CQtRoF/nWzgI810tMFUUiKHugo"
|
||||
)
|
||||
|
||||
// newLegacyDB builds a tiny synthetic legacy Casdoor iam.db with snake_case
|
||||
// columns (as xorm emits) — including the credential-critical `password` column
|
||||
// and the Casdoor `id` UUID that the clean schema now carries verbatim as
|
||||
// schema.User.Id (the continuity `sub`).
|
||||
func newLegacyDB(t *testing.T, certPEM, keyPEM string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "iam.db")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE organization (
|
||||
owner text, name text, created_time text, display_name text,
|
||||
password_type text, init_score integer, is_personal integer)`,
|
||||
`CREATE TABLE cert (
|
||||
owner text, name text, created_time text, type text,
|
||||
crypto_algorithm text, bit_size integer, certificate text, private_key text)`,
|
||||
`CREATE TABLE application (
|
||||
owner text, name text, created_time text, display_name text,
|
||||
organization text, cert text, client_id text, client_secret text,
|
||||
enable_password integer, redirect_uris text)`,
|
||||
`CREATE TABLE provider (
|
||||
owner text, name text, created_time text, category text, type text,
|
||||
client_id text, client_secret text, user_mapping text)`,
|
||||
`CREATE TABLE user (
|
||||
owner text, name text, created_time text, updated_time text, id text,
|
||||
password text, password_type text, password_salt text, email text,
|
||||
display_name text, is_admin integer, signup_application text, github text)`,
|
||||
`CREATE TABLE role (
|
||||
owner text, name text, created_time text, display_name text,
|
||||
users text, is_enabled integer)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("create table: %v\n%s", err, s)
|
||||
}
|
||||
}
|
||||
|
||||
exec := func(q string, args ...any) {
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO organization VALUES(?,?,?,?,?,?,?)`,
|
||||
"admin", "hanzo", "2020-01-02T03:04:05Z", "Hanzo", "argon2id", 100, 0)
|
||||
exec(`INSERT INTO cert VALUES(?,?,?,?,?,?,?,?)`,
|
||||
"admin", "cert-hanzo", "2020-01-02T03:04:05Z", "x509", "RS256", 2048, certPEM, keyPEM)
|
||||
exec(`INSERT INTO application VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
"admin", "app-hanzo", "2020-01-02T03:04:05Z", "Hanzo App", "hanzo", "cert-hanzo",
|
||||
"client-abc", "secret-xyz", 1, `["https://hanzo.ai/callback"]`)
|
||||
exec(`INSERT INTO provider VALUES(?,?,?,?,?,?,?,?)`,
|
||||
"admin", "provider-github", "2020-01-02T03:04:05Z", "OAuth", "GitHub",
|
||||
"gh-id", "gh-secret", `{"id":"id","username":"login"}`)
|
||||
// User z: own password_type=argon2id.
|
||||
exec(`INSERT INTO user VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
"hanzo", "z", "2020-01-02T03:04:05Z", "2020-02-02T03:04:05Z", "uuid-0001",
|
||||
goldenDigest, "argon2id", "the-salt", "z@hanzo.ai", "Z", 1, "app-hanzo", "z-gh")
|
||||
// User fallback: EMPTY password_type — verification must fall back to the org's.
|
||||
exec(`INSERT INTO user VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
"hanzo", "fallback", "2020-01-03T03:04:05Z", "2020-02-03T03:04:05Z", "uuid-0002",
|
||||
goldenDigest, "", "", "fallback@hanzo.ai", "Fallback", 0, "app-hanzo", "")
|
||||
exec(`INSERT INTO role VALUES(?,?,?,?,?,?)`,
|
||||
"hanzo", "role-admin", "2020-01-02T03:04:05Z", "Admins", `["hanzo/z"]`, 1)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func genRSAPEM(t *testing.T) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}))
|
||||
// A stand-in certificate PEM — content is opaque to the migrator; what matters
|
||||
// is that the multi-line PEM survives byte-for-byte.
|
||||
certPEM = "-----BEGIN CERTIFICATE-----\nMIIB=stub=cert=material=\nfor=jwks=parity=test\n-----END CERTIFICATE-----\n"
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func openDest(t *testing.T) (orm.DB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := store.Open("sqlite", filepath.Join(dir, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open dest: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db, dir
|
||||
}
|
||||
|
||||
func TestMigrate_PreservesCredentialsAndKeysVerbatim(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
results, err := Migrate(ctx, src, dst, nil, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
byEntity := indexResults(results)
|
||||
|
||||
// ---- Organization: password_type must survive (it is the fallback scheme). ----
|
||||
org, err := store.GetOrganizationByName(ctx, dst, "hanzo")
|
||||
if err != nil || org == nil {
|
||||
t.Fatalf("org not migrated: %v", err)
|
||||
}
|
||||
if org.PasswordType != "argon2id" {
|
||||
t.Errorf("org.PasswordType = %q, want argon2id", org.PasswordType)
|
||||
}
|
||||
if org.CreatedTime != "2020-01-02T03:04:05Z" {
|
||||
t.Errorf("org.CreatedTime = %q, want the v1 stamp verbatim", org.CreatedTime)
|
||||
}
|
||||
if org.InitScore != 100 {
|
||||
t.Errorf("org.InitScore = %d, want 100", org.InitScore)
|
||||
}
|
||||
|
||||
// ---- Cert: PrivateKey + Certificate byte-for-byte (JWKS parity). ----
|
||||
cert, err := store.GetCert(ctx, dst, "admin", "cert-hanzo")
|
||||
if err != nil || cert == nil {
|
||||
t.Fatalf("cert not migrated: %v", err)
|
||||
}
|
||||
if cert.PrivateKey != keyPEM {
|
||||
t.Fatalf("cert.PrivateKey NOT verbatim:\n got %q\nwant %q", cert.PrivateKey, keyPEM)
|
||||
}
|
||||
if cert.Certificate != certPEM {
|
||||
t.Fatalf("cert.Certificate NOT verbatim:\n got %q\nwant %q", cert.Certificate, certPEM)
|
||||
}
|
||||
if cert.BitSize != 2048 || cert.CryptoAlgorithm != "RS256" {
|
||||
t.Errorf("cert metadata drift: bitSize=%d alg=%q", cert.BitSize, cert.CryptoAlgorithm)
|
||||
}
|
||||
|
||||
// ---- User z: PasswordHash verbatim, and cred.Verify succeeds end-to-end. ----
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("user z not migrated: %v", err)
|
||||
}
|
||||
if u.PasswordHash != goldenDigest {
|
||||
t.Fatalf("user.PasswordHash NOT verbatim:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
|
||||
}
|
||||
if u.PasswordType != "argon2id" {
|
||||
t.Errorf("user.PasswordType = %q, want argon2id", u.PasswordType)
|
||||
}
|
||||
if u.PasswordSalt != "the-salt" {
|
||||
t.Errorf("user.PasswordSalt = %q, want the-salt", u.PasswordSalt)
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
t.Error("user.IsAdmin = false, want true (bool 1 -> true)")
|
||||
}
|
||||
if u.GitHub != "z-gh" {
|
||||
t.Errorf("user.GitHub = %q, want z-gh (federated connector column)", u.GitHub)
|
||||
}
|
||||
if u.Email != "z@hanzo.ai" {
|
||||
t.Errorf("user.Email = %q", u.Email)
|
||||
}
|
||||
// SUB CONTINUITY: the Casdoor per-row UUID migrates verbatim into User.Id — the
|
||||
// value the OIDC `sub` will carry, so a migrated user's sub is byte-identical
|
||||
// across the cutover.
|
||||
if u.Id != "uuid-0001" {
|
||||
t.Errorf("user.Id = %q, want uuid-0001 (the migrated continuity sub)", u.Id)
|
||||
}
|
||||
// THE assertion that matters: the migrated hash verifies under the clean
|
||||
// verifier, resolving the scheme from the row exactly as login does.
|
||||
typ := cred.Resolve(u.PasswordType, org.PasswordType)
|
||||
if !cred.Verify(typ, goldenPassword, u.PasswordHash) {
|
||||
t.Fatal("cred.Verify REJECTED the migrated argon2id hash — login would fail at cutover")
|
||||
}
|
||||
if cred.Verify(typ, "wrong-password", u.PasswordHash) {
|
||||
t.Fatal("cred.Verify ACCEPTED a wrong password against the migrated hash")
|
||||
}
|
||||
|
||||
// ---- User fallback: empty type resolves through the org's argon2id. ----
|
||||
fb, err := store.GetUserByName(ctx, dst, "hanzo", "fallback")
|
||||
if err != nil || fb == nil {
|
||||
t.Fatalf("user fallback not migrated: %v", err)
|
||||
}
|
||||
if fb.PasswordType != "" {
|
||||
t.Errorf("fallback user should keep empty PasswordType, got %q", fb.PasswordType)
|
||||
}
|
||||
fbType := cred.Resolve(fb.PasswordType, org.PasswordType)
|
||||
if fbType != "argon2id" {
|
||||
t.Fatalf("org fallback resolve = %q, want argon2id", fbType)
|
||||
}
|
||||
if !cred.Verify(fbType, goldenPassword, fb.PasswordHash) {
|
||||
t.Fatal("org-fallback verification of migrated hash failed")
|
||||
}
|
||||
|
||||
// ---- Application + Provider round-trip (incl. a JSON-typed column). ----
|
||||
app, err := store.GetApplicationByName(ctx, dst, "admin", "app-hanzo")
|
||||
if err != nil || app == nil {
|
||||
t.Fatalf("app not migrated: %v", err)
|
||||
}
|
||||
if app.ClientId != "client-abc" || app.Cert != "cert-hanzo" || app.Organization != "hanzo" {
|
||||
t.Errorf("app fields drift: clientId=%q cert=%q org=%q", app.ClientId, app.Cert, app.Organization)
|
||||
}
|
||||
if len(app.RedirectUris) != 1 || app.RedirectUris[0] != "https://hanzo.ai/callback" {
|
||||
t.Errorf("app.RedirectUris JSON column not decoded: %#v", app.RedirectUris)
|
||||
}
|
||||
prov, err := store.GetProvider(ctx, dst, "admin", "provider-github")
|
||||
if err != nil || prov == nil {
|
||||
t.Fatalf("provider not migrated: %v", err)
|
||||
}
|
||||
wantMap := map[string]string{"id": "id", "username": "login"}
|
||||
if !reflect.DeepEqual(prov.UserMapping, wantMap) {
|
||||
t.Errorf("provider.UserMapping = %#v, want %#v", prov.UserMapping, wantMap)
|
||||
}
|
||||
|
||||
// ---- The Casdoor `id` UUID now has a clean home (User.Id): it must NOT be a gap. ----
|
||||
if got := byEntity["users"]; got == nil || contains(got.UnmappedCols, "id") {
|
||||
t.Errorf("legacy user column 'id' must now be MAPPED to User.Id (the continuity sub), still reported unmapped: %v",
|
||||
gapCols(got))
|
||||
}
|
||||
if fb, _ := store.GetUserByName(ctx, dst, "hanzo", "fallback"); fb == nil || fb.Id != "uuid-0002" {
|
||||
t.Errorf("fallback user.Id = %v, want uuid-0002 (every casdoor row's UUID carries)", fb)
|
||||
}
|
||||
|
||||
// ---- Row counts: everything read was created. ----
|
||||
assertCounts(t, byEntity["users"], 2, 2, 0, 0)
|
||||
assertCounts(t, byEntity["organizations"], 1, 1, 0, 0)
|
||||
assertCounts(t, byEntity["certs"], 1, 1, 0, 0)
|
||||
assertCounts(t, byEntity["applications"], 1, 1, 0, 0)
|
||||
assertCounts(t, byEntity["providers"], 1, 1, 0, 0)
|
||||
}
|
||||
|
||||
func TestMigrate_Idempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
if _, err := Migrate(ctx, src, dst, nil, options{}); err != nil {
|
||||
t.Fatalf("first pass: %v", err)
|
||||
}
|
||||
firstCounts := kindCounts(ctx, t, dst)
|
||||
|
||||
// Second pass must be a pure no-op: nothing created, nothing updated.
|
||||
results, err := Migrate(ctx, src, dst, nil, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("second pass: %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Created != 0 || r.Updated != 0 {
|
||||
t.Errorf("%s: re-run not idempotent (created=%d updated=%d), want 0/0",
|
||||
r.Entity, r.Created, r.Updated)
|
||||
}
|
||||
if r.Read != r.Unchanged {
|
||||
t.Errorf("%s: read=%d but unchanged=%d — re-run should classify every row unchanged",
|
||||
r.Entity, r.Read, r.Unchanged)
|
||||
}
|
||||
}
|
||||
|
||||
// No duplicate rows: per-kind counts are identical after the second pass.
|
||||
secondCounts := kindCounts(ctx, t, dst)
|
||||
if !reflect.DeepEqual(firstCounts, secondCounts) {
|
||||
t.Errorf("row counts changed on re-run: %v -> %v", firstCounts, secondCounts)
|
||||
}
|
||||
|
||||
// And the hash is still exact after two passes.
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil || u.PasswordHash != goldenDigest {
|
||||
t.Fatalf("hash drifted after re-run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_DryRunWritesNothing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
results, err := Migrate(ctx, src, dst, nil, options{dryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run: %v", err)
|
||||
}
|
||||
// Dry-run predicts creates but writes nothing.
|
||||
for _, r := range results {
|
||||
if r.TableMissing {
|
||||
continue
|
||||
}
|
||||
if r.Created != r.Read {
|
||||
t.Errorf("%s: dry-run should predict create for every row (read=%d created=%d)",
|
||||
r.Entity, r.Read, r.Created)
|
||||
}
|
||||
}
|
||||
counts := kindCounts(ctx, t, dst)
|
||||
for kind, n := range counts {
|
||||
if n != 0 {
|
||||
t.Errorf("dry-run wrote %d rows of kind %q, want 0", n, kind)
|
||||
}
|
||||
}
|
||||
// Second (real) run after a dry-run still creates everything.
|
||||
real, err := Migrate(ctx, src, dst, nil, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("real after dry: %v", err)
|
||||
}
|
||||
for _, r := range indexResultsSlice(real) {
|
||||
if r.TableMissing {
|
||||
continue
|
||||
}
|
||||
if r.Created != r.Read {
|
||||
t.Errorf("%s: real run after dry-run should create every row", r.Entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_OnlySelectsSubset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
results, err := Migrate(ctx, src, dst, []string{"users", "orgs"}, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("migrate subset: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 entities migrated, got %d", len(results))
|
||||
}
|
||||
// certs must NOT have been touched.
|
||||
if c, _ := store.GetCert(ctx, dst, "admin", "cert-hanzo"); c != nil {
|
||||
t.Error("--only users,orgs should not migrate certs")
|
||||
}
|
||||
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u == nil {
|
||||
t.Error("--only users,orgs must migrate users")
|
||||
}
|
||||
|
||||
// An unknown selector is a hard error, never a silent skip.
|
||||
if _, err := Migrate(ctx, src, dst, []string{"widgets"}, options{}); err == nil {
|
||||
t.Error("unknown --only entity must error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func indexResults(rs []*EntityResult) map[string]*EntityResult {
|
||||
m := map[string]*EntityResult{}
|
||||
for _, r := range rs {
|
||||
m[r.Entity] = r
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func indexResultsSlice(rs []*EntityResult) []*EntityResult { return rs }
|
||||
|
||||
func assertCounts(t *testing.T, r *EntityResult, read, created, updated, unchanged int) {
|
||||
t.Helper()
|
||||
if r == nil {
|
||||
t.Fatalf("nil result")
|
||||
}
|
||||
if r.Read != read || r.Created != created || r.Updated != updated || r.Unchanged != unchanged {
|
||||
t.Errorf("%s counts = read %d/created %d/updated %d/unchanged %d, want %d/%d/%d/%d",
|
||||
r.Entity, r.Read, r.Created, r.Updated, r.Unchanged, read, created, updated, unchanged)
|
||||
}
|
||||
}
|
||||
|
||||
func kindCounts(ctx context.Context, t *testing.T, db orm.DB) map[string]int64 {
|
||||
t.Helper()
|
||||
out := map[string]int64{}
|
||||
for _, kind := range schema.Kinds() {
|
||||
n, err := db.Query(kind).Count(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("count %s: %v", kind, err)
|
||||
}
|
||||
if n > 0 {
|
||||
out[kind] = int64(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gapCols(r *EntityResult) []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.UnmappedCols
|
||||
}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
c-4.5.6.db is a real database written by the C libsqlcipher 4.5.6 library,
|
||||
copied verbatim from github.com/hanzoai/sqlcipher@v0.1.0/testdata. It is keyed
|
||||
with the raw key 000102...1e1f (see enc_test.go: sqlcipherInteropKey).
|
||||
|
||||
The encrypted-source migrator test uses it ONLY as a source of SQLCipher's
|
||||
80-byte per-page reserve (SQLite header byte 20): DecryptFile yields a reserved
|
||||
plaintext canvas, modernc PRESERVES that reserve when the test overwrites the
|
||||
schema, and EncryptFile can then produce an encrypted shard the test fully
|
||||
controls. Pure Go cannot ORIGINATE reserved pages, so this vector bootstraps
|
||||
them. Its own schema/contents are irrelevant — the test wipes them.
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,292 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// WAL-INCLUSIVE extraction. The default encrypted path (encrypted.go, via
|
||||
// sqlcipher.DecryptFile) decrypts only the CHECKPOINTED main-db image of a
|
||||
// shard — any rows still sitting in that shard's uncheckpointed `-wal` are
|
||||
// invisible to it. On the live production store most org shards carry hundreds
|
||||
// of KB of uncheckpointed WAL, so a cutover built on the checkpointed image
|
||||
// alone undercounts users and locks the most-recent signups out. This file adds
|
||||
// the COMPLETE extraction: it drives the C `sqlcipher` binary (SQLCipher 4.x,
|
||||
// present on the fork's IAM pod at /usr/bin/sqlcipher) to checkpoint each
|
||||
// shard's WAL into a plaintext copy before the Migrate engine reads it.
|
||||
//
|
||||
// Why not hanzoai/sqlite's keyed open: it force-sets journal_mode=WAL (a WRITE)
|
||||
// on open and fails "disk I/O error (10)" on a copied shard, so it cannot do a
|
||||
// WAL-inclusive read. The C sqlcipher shell has no such constraint, so the WAL
|
||||
// merge is delegated to it. The crypto KEY still comes from the SAME pure-Go
|
||||
// derive+unwrap the checkpointed path uses (encrypted.go deriveDEK) — never
|
||||
// re-derived here.
|
||||
//
|
||||
// KEY HANDLING (non-negotiable): the 32-byte DEK reaches the child ONLY inside
|
||||
// the SQL script on STDIN, as a raw x'…' key — NEVER on argv, NEVER logged. The
|
||||
// script bytes and the hex are zeroed after the child returns, and the child's
|
||||
// stderr is scrubbed of the hex before it can enter an error string.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// walMode selects and configures the WAL-inclusive extraction path. The zero
|
||||
// value (enabled=false, ignoreWAL=false) is the default checkpointed path guarded
|
||||
// against a non-empty uncheckpointed WAL, so nothing regresses.
|
||||
type walMode struct {
|
||||
enabled bool // --wal-inclusive: checkpoint each shard's -wal into the plaintext copy (complete extraction)
|
||||
ignoreWAL bool // --ignore-wal: in the default path, proceed even if a shard has a non-empty -wal, INTENTIONALLY dropping those rows
|
||||
bin string // C sqlcipher binary (path, or a name resolved on PATH)
|
||||
}
|
||||
|
||||
// sqliteHeader is the 16-byte magic every SQLite database file begins with. A
|
||||
// valid plaintext export MUST start with it; a wrong key makes sqlcipher_export
|
||||
// produce nothing or garbage, and catching that here is what stops a garbage
|
||||
// store from ever being written.
|
||||
var sqliteHeader = []byte("SQLite format 3\x00")
|
||||
|
||||
// preflightWAL verifies the sqlcipher binary is resolvable BEFORE any shard is
|
||||
// touched, so --wal-inclusive can never silently degrade to the WAL-blind
|
||||
// checkpointed path when the binary is absent.
|
||||
func preflightWAL(w walMode) error {
|
||||
if !w.enabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(w.bin) == "" {
|
||||
return fmt.Errorf("--wal-inclusive: no sqlcipher binary configured (use --sqlcipher-bin)")
|
||||
}
|
||||
if _, err := exec.LookPath(w.bin); err != nil {
|
||||
return fmt.Errorf("--wal-inclusive requires the C sqlcipher binary %q on PATH: %w", w.bin, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkpointShardToPlaintext produces a WAL-INCLUSIVE plaintext copy of one
|
||||
// encrypted shard and returns its path plus the temp dir to shred. It copies the
|
||||
// shard's iam.db (+ -wal/-shm, if present) into a fresh 0700 dir so checkpointing
|
||||
// operates on a COPY and never the live file, derives the DEK with the shared
|
||||
// pure-Go recipe, then drives the C sqlcipher shell to checkpoint(TRUNCATE) the
|
||||
// WAL into the copy and sqlcipher_export a plaintext db. On ANY error it shreds
|
||||
// the temp dir (which may already hold decrypted pages) before returning.
|
||||
func checkpointShardToPlaintext(sh encShard, master []byte, workDir, bin string) (plainPath, tmpDir string, err error) {
|
||||
dir, err := os.MkdirTemp(workDir, "iam-migrate-wal-*")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("create work temp dir: %w", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
shredDir(dir)
|
||||
return "", "", fmt.Errorf("lock down work temp dir: %w", err)
|
||||
}
|
||||
// The temp dir holds a decrypted copy + the plaintext export, so it must be
|
||||
// shredded on ANY error path. An explicit ok flag (not the named err/tmpDir
|
||||
// returns, which an error `return "", "", err` would blank out first) drives
|
||||
// the cleanup: shred unless we hand the dir back to the caller on success.
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
shredDir(dir)
|
||||
}
|
||||
}()
|
||||
|
||||
srcCopy := filepath.Join(dir, "src.db")
|
||||
plain := filepath.Join(dir, "plain.db")
|
||||
// Both paths are embedded (single-quoted) in the sqlcipher script; a quote or
|
||||
// newline in them would break the ATTACH/open, so reject it rather than emit a
|
||||
// malformed statement. os.MkdirTemp's own suffix never contains these, so this
|
||||
// only guards a hostile --work-dir.
|
||||
if strings.ContainsAny(srcCopy, "'\n") || strings.ContainsAny(plain, "'\n") {
|
||||
return "", "", fmt.Errorf("work-dir path contains an unsupported character (%q or newline)", "'")
|
||||
}
|
||||
|
||||
// READ-CONSISTENCY FENCE (HIGH #1). A shard is three on-disk files — iam.db,
|
||||
// -wal, -shm — and copying them as three separate reads is only sound if the
|
||||
// source does not change across the copy window. If a checkpoint fires mid-copy
|
||||
// it moves committed frames out of the -wal we already read into a main db we
|
||||
// did not (then TRUNCATEs the -wal), and those rows vanish from the copied set
|
||||
// with no error. So snapshot the (existence, size, mtime) identity of all three
|
||||
// files BEFORE the copy and re-snapshot AFTER: if anything moved, the source was
|
||||
// written during the window and the copied triple may be internally
|
||||
// inconsistent — abort LOUDLY rather than checkpoint a mismatched main/-wal
|
||||
// pair. At cutover the source is a frozen VolumeSnapshot with IAM writes frozen,
|
||||
// so this never trips in practice; it is the defense-in-depth that turns
|
||||
// "silently copy a live shard" into a hard refusal. Reads never advance mtime,
|
||||
// so our own copy cannot false-trip the fence; the bracket is exactly the read
|
||||
// window, so any change inside it is caught and any change outside it is
|
||||
// irrelevant to the bytes we captured.
|
||||
before := statShard(sh.path)
|
||||
if walCopyHook != nil {
|
||||
walCopyHook(sh.path) // test seam only (nil in production): inject a writer to prove the fence aborts
|
||||
}
|
||||
if err := copyFile(sh.path, srcCopy); err != nil {
|
||||
return "", "", fmt.Errorf("copy shard main db: %w", err)
|
||||
}
|
||||
for _, suf := range []string{"-wal", "-shm"} {
|
||||
if _, statErr := os.Stat(sh.path + suf); statErr == nil {
|
||||
if err := copyFile(sh.path+suf, srcCopy+suf); err != nil {
|
||||
return "", "", fmt.Errorf("copy shard %q: %w", suf, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if after := statShard(sh.path); after != before {
|
||||
return "", "", fmt.Errorf("shard %s changed on disk during copy (source not quiescent — iam.db/-wal/-shm existence, size, or mtime moved): refusing to migrate a possibly-inconsistent snapshot that could silently drop committed WAL rows; run against a frozen snapshot with IAM writes frozen, or a shard with no live writer", sh.label)
|
||||
}
|
||||
|
||||
dek, err := deriveDEK(sh, master)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer zero(dek)
|
||||
|
||||
if err := runSQLCipherExport(bin, srcCopy, plain, dek); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Defensive drain check (RED LOW). After wal_checkpoint(TRUNCATE) the copy's
|
||||
// -wal must be fully drained into its main db; any bytes still there mean the
|
||||
// checkpoint could not apply every frame (a busy checkpoint), and the plaintext
|
||||
// export — taken from the main db — would then be missing rows. This asserts the
|
||||
// OUTCOME of the checkpoint (WAL emptied) directly, which is strictly stronger
|
||||
// than parsing the PRAGMA's busy column and is version-independent. On a private
|
||||
// copy no other connection holds, it never trips; it is the belt to -bail's
|
||||
// suspenders.
|
||||
if fi, statErr := os.Stat(srcCopy + "-wal"); statErr == nil && fi.Size() > 0 {
|
||||
return "", "", fmt.Errorf("shard %s: wal_checkpoint(TRUNCATE) left %d bytes uncheckpointed in the WAL — the export would miss those rows", sh.label, fi.Size())
|
||||
}
|
||||
if err := validatePlaintextSQLite(plain); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ok = true
|
||||
return plain, dir, nil
|
||||
}
|
||||
|
||||
// shardStat is the on-disk identity of one shard file: whether it exists, its
|
||||
// size, and its mtime as UnixNano. All fields are comparable, so a [3]shardStat of
|
||||
// (iam.db, -wal, -shm) can be compared with != to detect ANY change — including a
|
||||
// -wal that appears or disappears — across the copy window.
|
||||
type shardStat struct {
|
||||
exists bool
|
||||
size int64
|
||||
mtimeNs int64
|
||||
}
|
||||
|
||||
// statShard snapshots the identity of a shard's three on-disk files: the main db
|
||||
// at base, base+"-wal", and base+"-shm". A missing file is the zero shardStat
|
||||
// (exists=false), so a -wal materializing or being truncated away mid-copy is
|
||||
// itself a detected change.
|
||||
func statShard(base string) [3]shardStat {
|
||||
var s [3]shardStat
|
||||
for i, suf := range []string{"", "-wal", "-shm"} {
|
||||
if fi, err := os.Stat(base + suf); err == nil {
|
||||
s[i] = shardStat{exists: true, size: fi.Size(), mtimeNs: fi.ModTime().UnixNano()}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// walCopyHook is a TEST SEAM. When non-nil it is invoked exactly once, immediately
|
||||
// after the pre-copy identity snapshot, so a test can deterministically inject a
|
||||
// concurrent writer and prove the read-consistency fence aborts. It is nil in
|
||||
// production — no behavior, no cost. (Same pattern as the stdlib's testHook vars.)
|
||||
var walCopyHook func(shardBase string)
|
||||
|
||||
// runSQLCipherExport drives the C sqlcipher shell to checkpoint srcCopy's WAL
|
||||
// into its main db and export a plaintext db to plainPath. The DEK is written as
|
||||
// a raw x'…' hex key INSIDE the stdin script — never on argv. The exact SQL is
|
||||
// the checkpoint-then-export recipe the migrator was validated against:
|
||||
//
|
||||
// PRAGMA key = "x'<dek-hex>'";
|
||||
// PRAGMA cipher_page_size = 4096;
|
||||
// PRAGMA wal_checkpoint(TRUNCATE);
|
||||
// ATTACH DATABASE '<plainPath>' AS plaintext KEY '';
|
||||
// SELECT sqlcipher_export('plaintext');
|
||||
// DETACH DATABASE plaintext;
|
||||
//
|
||||
// -bail makes the shell stop and exit non-zero on the first error, so a wrong
|
||||
// key (which errors on the first read of an encrypted page: "file is not a
|
||||
// database") fails LOUDLY instead of yielding a partial export we might mistake
|
||||
// for success. The script bytes and the hex are scrubbed on return; the child's
|
||||
// stderr is scrubbed of the hex before it can reach an error string.
|
||||
func runSQLCipherExport(bin, srcCopy, plainPath string, dek []byte) error {
|
||||
hexDEK := make([]byte, hex.EncodedLen(len(dek)))
|
||||
hex.Encode(hexDEK, dek)
|
||||
defer zero(hexDEK)
|
||||
|
||||
var script bytes.Buffer
|
||||
script.WriteString(`PRAGMA key = "x'`)
|
||||
script.Write(hexDEK)
|
||||
script.WriteString("'\";\n")
|
||||
script.WriteString("PRAGMA cipher_page_size = 4096;\n")
|
||||
script.WriteString("PRAGMA wal_checkpoint(TRUNCATE);\n")
|
||||
script.WriteString("ATTACH DATABASE '" + plainPath + "' AS plaintext KEY '';\n")
|
||||
script.WriteString("SELECT sqlcipher_export('plaintext');\n")
|
||||
script.WriteString("DETACH DATABASE plaintext;\n")
|
||||
scriptBytes := script.Bytes()
|
||||
defer zero(scriptBytes) // scrub the DEK-bearing script from memory
|
||||
|
||||
// The db path on argv is NOT secret; the key rides stdin only.
|
||||
cmd := exec.Command(bin, "-bail", srcCopy)
|
||||
cmd.Stdin = bytes.NewReader(scriptBytes)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
|
||||
// Defensively scrub the DEK hex from any child diagnostics before surfacing it.
|
||||
safe := bytes.TrimSpace(bytes.ReplaceAll(stderr.Bytes(), hexDEK, []byte("<redacted-key>")))
|
||||
if runErr != nil {
|
||||
return fmt.Errorf("sqlcipher WAL-inclusive export failed (wrong master key or corrupt shard?): %v: %s", runErr, safe)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePlaintextSQLite fails loudly unless path is a non-empty SQLite database
|
||||
// (starts with the 16-byte SQLite magic). This is the gate that keeps a wrong key
|
||||
// — whose export is empty or non-SQLite — from ever reaching the Migrate engine.
|
||||
func validatePlaintextSQLite(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plaintext export missing (decrypt/export likely failed): %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
head := make([]byte, len(sqliteHeader))
|
||||
if _, err := io.ReadFull(f, head); err != nil || !bytes.Equal(head, sqliteHeader) {
|
||||
return fmt.Errorf("plaintext export at %s is not a valid SQLite database (wrong key or failed export)", filepath.Base(path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies src to a fresh 0600 dst (O_EXCL: the temp dir is fresh, so a
|
||||
// pre-existing dst would mean a collision we want to fail on).
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// shredDir shreds every file in dir (each may hold plaintext credential material)
|
||||
// and removes the dir. Best-effort by design, like shred: a failure never blocks
|
||||
// the migration.
|
||||
func shredDir(dir string) {
|
||||
if entries, err := os.ReadDir(dir); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
shred(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Tests for the two silent-data-loss guards RED flagged on the WAL-inclusive
|
||||
// migrator:
|
||||
//
|
||||
// - HIGH #1 read-consistency fence: a shard mutated during the multi-file copy
|
||||
// window must ABORT (never silently checkpoint a mismatched main/-wal pair).
|
||||
// - HIGH #2 WAL-blind default: the default checkpointed path must HARD-FAIL on a
|
||||
// shard carrying a non-empty uncheckpointed -wal unless --wal-inclusive (capture
|
||||
// it) or --ignore-wal (intentionally drop it).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// mustWriteFile writes data to path, creating parent dirs — a tiny fixture helper
|
||||
// for the shards these guard tests only ever stat (contents are irrelevant to the
|
||||
// guard, which keys off the -wal's size).
|
||||
func mustWriteFile(t *testing.T, path string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCheckpointedWAL exercises the HIGH #2 gate as a pure function across
|
||||
// its three inputs: a clean/empty -wal passes, a non-empty -wal hard-fails with an
|
||||
// actionable message naming both escape hatches, and --ignore-wal proceeds past it.
|
||||
func TestGuardCheckpointedWAL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
cleanPath := filepath.Join(dir, "clean", "iam.db")
|
||||
mustWriteFile(t, cleanPath, []byte("main")) // no -wal at all
|
||||
|
||||
emptyWALPath := filepath.Join(dir, "emptywal", "iam.db")
|
||||
mustWriteFile(t, emptyWALPath, []byte("main"))
|
||||
mustWriteFile(t, emptyWALPath+"-wal", nil) // 0-byte -wal: nothing to lose
|
||||
|
||||
dirtyPath := filepath.Join(dir, "dirty", "iam.db")
|
||||
mustWriteFile(t, dirtyPath, []byte("main"))
|
||||
mustWriteFile(t, dirtyPath+"-wal", []byte("uncheckpointed frames"))
|
||||
|
||||
clean := encShard{label: "global", path: cleanPath}
|
||||
emptyWAL := encShard{label: "org:emptywal", path: emptyWALPath}
|
||||
dirty := encShard{label: "org:dirty", path: dirtyPath}
|
||||
|
||||
t.Run("clean_and_empty_wal_pass", func(t *testing.T) {
|
||||
if err := guardCheckpointedWAL([]encShard{clean, emptyWAL}, false); err != nil {
|
||||
t.Fatalf("a missing/empty -wal must pass the guard, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non_empty_wal_hard_fails", func(t *testing.T) {
|
||||
err := guardCheckpointedWAL([]encShard{clean, dirty}, false)
|
||||
if err == nil {
|
||||
t.Fatal("a non-empty uncheckpointed -wal must hard-fail without a flag")
|
||||
}
|
||||
for _, want := range []string{"org:dirty", "SILENTLY DROP", "--wal-inclusive", "--ignore-wal"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("hard-fail message missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignore_wal_proceeds", func(t *testing.T) {
|
||||
if err := guardCheckpointedWAL([]encShard{clean, dirty}, true); err != nil {
|
||||
t.Fatalf("--ignore-wal must proceed past a non-empty -wal, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDefaultPath_NonEmptyWAL_Integration drives the full runEncrypted default
|
||||
// path against a REAL pure-Go-encrypted shard that carries a non-empty
|
||||
// uncheckpointed -wal: without a flag it must abort and write nothing; with
|
||||
// --ignore-wal it proceeds and migrates the checkpointed main-db user. No C
|
||||
// sqlcipher is needed — the default path never invokes it.
|
||||
func TestDefaultPath_NonEmptyWAL_Integration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
master := randKey(t)
|
||||
datadir := t.TempDir()
|
||||
shardPath := writeGlobalUserShard(t, datadir, master) // real encrypted global shard: user hanzo/z (goldenDigest)
|
||||
|
||||
// A non-empty -wal beside the shard. Its bytes are never parsed by the default
|
||||
// DecryptFile path (which reads only the main db); the guard keys off its size.
|
||||
if err := os.WriteFile(shardPath+"-wal", []byte("frames the checkpointed path cannot see"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const env = "MIGRATE_V1_TEST_MASTER_KEY"
|
||||
t.Setenv(env, hex.EncodeToString(master))
|
||||
|
||||
t.Run("default_hard_fails_and_writes_nothing", func(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
err := runEncrypted(ctx, datadir, env, t.TempDir(), dest, false, nil, walMode{})
|
||||
if err == nil {
|
||||
t.Fatal("default path must refuse a shard with a non-empty uncheckpointed -wal")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--wal-inclusive") || !strings.Contains(err.Error(), "--ignore-wal") {
|
||||
t.Errorf("error must name both escape hatches, got: %v", err)
|
||||
}
|
||||
assertDestEmpty(t, ctx, dest)
|
||||
})
|
||||
|
||||
t.Run("ignore_wal_proceeds_and_migrates_main", func(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
if err := runEncrypted(ctx, datadir, env, t.TempDir(), dest, false, nil, walMode{ignoreWAL: true}); err != nil {
|
||||
t.Fatalf("--ignore-wal must proceed, got: %v", err)
|
||||
}
|
||||
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen dest: %v", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("--ignore-wal did not migrate the checkpointed main-db user: %v", err)
|
||||
}
|
||||
if u.PasswordHash != goldenDigest {
|
||||
t.Fatalf("main-db user hash not verbatim through --ignore-wal:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWALInclusive_SourceNotQuiescent_Aborts proves the HIGH #1 read-consistency
|
||||
// fence: if the live source shard is mutated during the copy window — a concurrent
|
||||
// commit growing the -wal, or a concurrent checkpoint moving the main db's mtime —
|
||||
// the run ABORTS loudly, writes nothing, and shreds the work-dir. It never
|
||||
// silently checkpoints a main/-wal pair copied at different instants. The
|
||||
// concurrent writer is injected deterministically via the walCopyHook test seam,
|
||||
// fired immediately after the pre-copy snapshot; the fence aborts before the C
|
||||
// sqlcipher binary is ever invoked, so its behavior is irrelevant here.
|
||||
func TestWALInclusive_SourceNotQuiescent_Aborts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() { walCopyHook = nil })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(t *testing.T, base string) // perturb the live source (base = <datadir>/iam.db)
|
||||
}{
|
||||
{
|
||||
name: "wal_grows_during_copy", // a concurrent commit appends frames to -wal
|
||||
mutate: func(t *testing.T, base string) {
|
||||
f, err := os.OpenFile(base+"-wal", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write([]byte("newly committed frames")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "main_mtime_moves_during_copy", // a concurrent checkpoint rewrites the main db
|
||||
mutate: func(t *testing.T, base string) {
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(base, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
master := randKey(t)
|
||||
datadir := t.TempDir()
|
||||
shardPath := writeGlobalUserShard(t, datadir, master)
|
||||
// Pre-seat a small -wal so the "grows" case appends and the "mtime" case
|
||||
// leaves a stable -wal that must NOT itself trip the fence.
|
||||
if err := os.WriteFile(shardPath+"-wal", []byte("frame0"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
|
||||
|
||||
walCopyHook = func(base string) { tc.mutate(t, base) }
|
||||
t.Cleanup(func() { walCopyHook = nil })
|
||||
|
||||
workDir := t.TempDir()
|
||||
dest := t.TempDir()
|
||||
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil,
|
||||
walMode{enabled: true, bin: os.Args[0]})
|
||||
if err == nil {
|
||||
t.Fatal("a source mutated during the copy window must abort — never silently proceed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not quiescent") && !strings.Contains(err.Error(), "changed on disk") {
|
||||
t.Errorf("abort must name the non-quiescence, got: %v", err)
|
||||
}
|
||||
assertDestEmpty(t, ctx, dest)
|
||||
if left, _ := os.ReadDir(workDir); len(left) != 0 {
|
||||
t.Errorf("work-dir not shredded after a fenced abort: %v", left)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
hsqlite "github.com/hanzoai/sqlite"
|
||||
|
||||
"github.com/hanzoai/iam/internal/cred"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// TestMain lets the test binary re-exec itself as a FAKE sqlcipher so the
|
||||
// exec-orchestration tests run with NO external dependency under CGO_ENABLED=0:
|
||||
// when MIGRATE_V1_FAKE_SQLCIPHER is set, the process acts as the sqlcipher child
|
||||
// (recording its argv + stdin, then producing/omitting a plaintext export) and
|
||||
// exits before any Go test runs.
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("MIGRATE_V1_FAKE_SQLCIPHER") != "" {
|
||||
fakeSQLCipherMain()
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
var attachRe = regexp.MustCompile(`ATTACH DATABASE '([^']*)' AS plaintext`)
|
||||
|
||||
// fakeSQLCipherMain impersonates the C sqlcipher shell. It records the argv it
|
||||
// was invoked with (to prove the DEK is NOT there) and the stdin script it
|
||||
// received (to prove the DEK rides stdin), then behaves per FAKE_MODE:
|
||||
//
|
||||
// "" success — write a REAL plaintext SQLite db (golden user) to the
|
||||
// ATTACH target, exit 0.
|
||||
// "garbage" write a NON-SQLite file to the ATTACH target, exit 0 — the
|
||||
// migrator's plaintext validation must reject it.
|
||||
// "exit1" print to stderr and exit 1 — a non-zero child must fail the run.
|
||||
func fakeSQLCipherMain() {
|
||||
if p := os.Getenv("FAKE_ARGV_OUT"); p != "" {
|
||||
_ = os.WriteFile(p, []byte(strings.Join(os.Args[1:], "\x00")), 0o600)
|
||||
}
|
||||
script, _ := io.ReadAll(os.Stdin)
|
||||
if p := os.Getenv("FAKE_STDIN_OUT"); p != "" {
|
||||
_ = os.WriteFile(p, script, 0o600)
|
||||
}
|
||||
target := ""
|
||||
if mm := attachRe.FindSubmatch(script); mm != nil {
|
||||
target = string(mm[1])
|
||||
}
|
||||
|
||||
switch os.Getenv("FAKE_MODE") {
|
||||
case "exit1":
|
||||
_, _ = os.Stderr.WriteString("fake sqlcipher: simulated failure\n")
|
||||
os.Exit(1)
|
||||
case "garbage":
|
||||
if target != "" {
|
||||
_ = os.WriteFile(target, []byte("NOT A SQLITE DATABASE"), 0o600)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
_, _ = os.Stderr.WriteString("fake sqlcipher: no ATTACH target in script\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
if err := writeFakePlaintext(target); err != nil {
|
||||
_, _ = os.Stderr.WriteString("fake sqlcipher: " + err.Error() + "\n")
|
||||
os.Exit(4)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// writeFakePlaintext writes a real plaintext SQLite db (a single golden-digest
|
||||
// user) to path via the SAME modernc "sqlite" driver the migrator reads with —
|
||||
// so the orchestration test exercises the true Migrate → cred.Verify chain
|
||||
// without any real decryption.
|
||||
func writeFakePlaintext(path string) error {
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, email text)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO "user" VALUES(?,?,?,?,?,?,?)`,
|
||||
"hanzo", "z", "2020-01-02T03:04:05Z", "uuid-fake", goldenDigest, "argon2id", "z@hanzo.ai")
|
||||
return err
|
||||
}
|
||||
|
||||
// --- helpers shared by the WAL tests ---
|
||||
|
||||
func randKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
if _, err := rand.Read(k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// writeGlobalUserShard writes a pure-Go encrypted GLOBAL shard whose only table
|
||||
// is a golden-digest user — enough for the exec-orchestration tests, which never
|
||||
// actually decrypt it (the fake ignores its contents; only its .dek sidecar is
|
||||
// unwrapped to prove the real key path).
|
||||
func writeGlobalUserShard(t *testing.T, datadir string, master []byte) string {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(datadir, "iam.db")
|
||||
writeEncryptedShard(t, dbPath, master, hsqlite.PrincipalGlobal, globalPrincipalID, func(db *sql.DB) {
|
||||
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, password text, password_type text)`)
|
||||
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?)`, "hanzo", "z", "2020-01-02T03:04:05Z", goldenDigest, "argon2id")
|
||||
})
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// expectedDEKHex recomputes the 64-hex shard DEK the migrator will derive, so a
|
||||
// test can assert the exact key is present on the child's stdin and absent from
|
||||
// its argv.
|
||||
func expectedDEKHex(t *testing.T, dbPath string, master []byte, pt hsqlite.PrincipalType, pid string) string {
|
||||
t.Helper()
|
||||
kek, err := hsqlite.DeriveKey(master, pt, pid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrapped, err := os.ReadFile(dbPath + ".dek")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dek, err := hsqlite.UnwrapDEK(kek, wrapped, hsqlite.PrincipalAAD(pt, pid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hex.EncodeToString(dek)
|
||||
}
|
||||
|
||||
// TestWALInclusive_Orchestration_KeyOffArgv is the pure-Go proof of the exec
|
||||
// orchestration: with a fake sqlcipher (this test binary re-exec'd), it asserts
|
||||
// the temp copy is made, the DEK reaches the child ONLY on stdin (never argv),
|
||||
// the golden user flows through Migrate → cred.Verify, and the work-dir is
|
||||
// shredded clean. No external binary, no real decryption — deterministic under
|
||||
// CGO_ENABLED=0.
|
||||
func TestWALInclusive_Orchestration_KeyOffArgv(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
master := randKey(t)
|
||||
datadir := t.TempDir()
|
||||
dbPath := writeGlobalUserShard(t, datadir, master) // single global-only shard
|
||||
dekHex := expectedDEKHex(t, dbPath, master, hsqlite.PrincipalGlobal, globalPrincipalID)
|
||||
|
||||
argvOut := filepath.Join(t.TempDir(), "argv")
|
||||
stdinOut := filepath.Join(t.TempDir(), "stdin")
|
||||
t.Setenv("MIGRATE_V1_FAKE_SQLCIPHER", "1")
|
||||
t.Setenv("FAKE_ARGV_OUT", argvOut)
|
||||
t.Setenv("FAKE_STDIN_OUT", stdinOut)
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
|
||||
|
||||
workDir := t.TempDir()
|
||||
dest := t.TempDir()
|
||||
wal := walMode{enabled: true, bin: os.Args[0]}
|
||||
if err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, wal); err != nil {
|
||||
t.Fatalf("runEncrypted --wal-inclusive: %v", err)
|
||||
}
|
||||
|
||||
// The golden user flowed through the fake's plaintext export and verifies.
|
||||
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen dest: %v", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("user not migrated through --wal-inclusive path: %v", err)
|
||||
}
|
||||
if !cred.Verify(cred.Resolve(u.PasswordType, "argon2id"), goldenPassword, u.PasswordHash) {
|
||||
t.Fatal("golden verify failed through the --wal-inclusive path")
|
||||
}
|
||||
|
||||
// THE key-safety assertion: the DEK is on stdin, NEVER on argv.
|
||||
argv, err := os.ReadFile(argvOut)
|
||||
if err != nil {
|
||||
t.Fatalf("read recorded argv: %v", err)
|
||||
}
|
||||
if strings.Contains(string(argv), dekHex) {
|
||||
t.Fatal("DEK leaked onto the sqlcipher argv/command line")
|
||||
}
|
||||
if !strings.Contains(string(argv), "-bail") {
|
||||
t.Errorf("expected -bail on argv (fail-loud), got %q", argv)
|
||||
}
|
||||
script, err := os.ReadFile(stdinOut)
|
||||
if err != nil {
|
||||
t.Fatalf("read recorded stdin: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(script), dekHex) {
|
||||
t.Fatal("DEK not on the child's stdin — the key must ride stdin, not argv")
|
||||
}
|
||||
if !strings.Contains(string(script), "sqlcipher_export('plaintext')") ||
|
||||
!strings.Contains(string(script), "wal_checkpoint(TRUNCATE)") {
|
||||
t.Fatalf("stdin script missing the checkpoint/export SQL:\n%s", script)
|
||||
}
|
||||
|
||||
// The decrypted-temp dir was shredded: the work-dir is empty.
|
||||
if left, _ := os.ReadDir(workDir); len(left) != 0 {
|
||||
t.Errorf("work-dir not shredded after run: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWALInclusive_GarbageExportFailsLoud proves a child that exits 0 but emits a
|
||||
// non-SQLite export (the signature of a wrong key) is caught by plaintext
|
||||
// validation: the run errors, nothing is written, and the work-dir is shredded.
|
||||
func TestWALInclusive_GarbageExportFailsLoud(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
master := randKey(t)
|
||||
datadir := t.TempDir()
|
||||
writeGlobalUserShard(t, datadir, master)
|
||||
|
||||
t.Setenv("MIGRATE_V1_FAKE_SQLCIPHER", "1")
|
||||
t.Setenv("FAKE_MODE", "garbage")
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
|
||||
|
||||
workDir := t.TempDir()
|
||||
dest := t.TempDir()
|
||||
wal := walMode{enabled: true, bin: os.Args[0]}
|
||||
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, wal)
|
||||
if err == nil {
|
||||
t.Fatal("a non-SQLite export must fail loudly, got nil error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a valid SQLite") {
|
||||
t.Errorf("error should name the invalid export, got: %v", err)
|
||||
}
|
||||
if left, _ := os.ReadDir(workDir); len(left) != 0 {
|
||||
t.Errorf("work-dir not shredded after a failed export: %v", left)
|
||||
}
|
||||
assertDestEmpty(t, ctx, dest)
|
||||
}
|
||||
|
||||
// TestWALInclusive_NonZeroExitFailsLoud proves a non-zero sqlcipher exit aborts
|
||||
// the run before any write.
|
||||
func TestWALInclusive_NonZeroExitFailsLoud(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
master := randKey(t)
|
||||
datadir := t.TempDir()
|
||||
writeGlobalUserShard(t, datadir, master)
|
||||
|
||||
t.Setenv("MIGRATE_V1_FAKE_SQLCIPHER", "1")
|
||||
t.Setenv("FAKE_MODE", "exit1")
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
|
||||
|
||||
workDir := t.TempDir()
|
||||
dest := t.TempDir()
|
||||
wal := walMode{enabled: true, bin: os.Args[0]}
|
||||
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, wal)
|
||||
if err == nil {
|
||||
t.Fatal("a non-zero sqlcipher exit must fail loudly, got nil error")
|
||||
}
|
||||
if left, _ := os.ReadDir(workDir); len(left) != 0 {
|
||||
t.Errorf("work-dir not shredded after a failed child: %v", left)
|
||||
}
|
||||
assertDestEmpty(t, ctx, dest)
|
||||
}
|
||||
|
||||
// TestWALInclusive_MissingBinaryFailsLoud proves --wal-inclusive with an absent
|
||||
// sqlcipher binary errors at preflight, before any shard is touched — it never
|
||||
// silently degrades to the WAL-blind checkpointed path.
|
||||
func TestWALInclusive_MissingBinaryFailsLoud(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
master := randKey(t)
|
||||
datadir := t.TempDir()
|
||||
writeGlobalUserShard(t, datadir, master)
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "no-such-sqlcipher")
|
||||
wal := walMode{enabled: true, bin: missing}
|
||||
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", t.TempDir(), t.TempDir(), false, nil, wal)
|
||||
if err == nil {
|
||||
t.Fatal("--wal-inclusive with a missing sqlcipher binary must fail loudly")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sqlcipher") {
|
||||
t.Errorf("error should name the missing sqlcipher binary, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- genuine uncheckpointed-WAL end-to-end (real C sqlcipher; skipped if absent) ---
|
||||
|
||||
func sqlcipherOrSkip(t *testing.T) string {
|
||||
t.Helper()
|
||||
bin, err := exec.LookPath("sqlcipher")
|
||||
if err != nil {
|
||||
t.Skip("C sqlcipher binary not on PATH; skipping genuine-WAL end-to-end test")
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func keyHeader(dek []byte) string {
|
||||
return `PRAGMA key = "x'` + hex.EncodeToString(dek) + `'";` + "\n" + "PRAGMA cipher_page_size = 4096;\n"
|
||||
}
|
||||
|
||||
// buildEncryptedDBViaC creates an encrypted db at dbPath under raw key dek and
|
||||
// runs seedSQL, checkpointed (rollback journal → all rows land in the main db).
|
||||
func buildEncryptedDBViaC(t *testing.T, bin, dbPath string, dek []byte, seedSQL string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(bin, "-bail", dbPath)
|
||||
cmd.Stdin = strings.NewReader(keyHeader(dek) + seedSQL)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build encrypted db: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// leaveRowsInWAL inserts insertSQL into an existing encrypted db and leaves the
|
||||
// frames in an UNCHECKPOINTED -wal — the exact production hazard. It switches the
|
||||
// db to WAL with autocheckpoint off, commits, then KILLS the writer before its
|
||||
// clean-close checkpoint can run (a held-open stdin keeps the connection alive so
|
||||
// no checkpoint fires; a sentinel file flushed right after COMMIT synchronizes
|
||||
// the kill deterministically).
|
||||
func leaveRowsInWAL(t *testing.T, bin, dbPath string, dek []byte, insertSQL string) {
|
||||
t.Helper()
|
||||
sentinel := dbPath + ".committed"
|
||||
script := keyHeader(dek) +
|
||||
"PRAGMA journal_mode=WAL;\n" +
|
||||
"PRAGMA wal_autocheckpoint=0;\n" +
|
||||
"PRAGMA synchronous=FULL;\n" +
|
||||
"BEGIN IMMEDIATE;\n" + insertSQL + "\nCOMMIT;\n" +
|
||||
".output '" + sentinel + "'\n" +
|
||||
"SELECT 'committed';\n" +
|
||||
".output stdout\n" // flushes+closes the sentinel; then blocks on stdin
|
||||
cmd := exec.Command(bin, dbPath)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(stdin, script); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Do NOT close stdin — the shell blocks reading the next line, keeping the
|
||||
// connection open so no close-checkpoint runs.
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for {
|
||||
if fi, statErr := os.Stat(sentinel); statErr == nil && fi.Size() > 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatal("timed out waiting for the WAL commit sentinel")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
_ = cmd.Process.Kill() // kill before clean close → WAL stays uncheckpointed
|
||||
_ = stdin.Close()
|
||||
_ = cmd.Wait()
|
||||
if fi, err := os.Stat(dbPath + "-wal"); err != nil || fi.Size() == 0 {
|
||||
t.Fatalf("fixture has no uncheckpointed -wal (err=%v)", err)
|
||||
}
|
||||
_ = os.Remove(sentinel)
|
||||
}
|
||||
|
||||
// writeDEKSidecar wraps a raw DEK under the (master, principal) KEK and writes the
|
||||
// .dek sidecar the migrator unwraps — the inverse of deriveDEK.
|
||||
func writeDEKSidecar(t *testing.T, dbPath string, master []byte, pt hsqlite.PrincipalType, pid string, dek []byte) {
|
||||
t.Helper()
|
||||
kek, err := hsqlite.DeriveKey(master, pt, pid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrapped, err := hsqlite.WrapDEK(kek, dek, hsqlite.PrincipalAAD(pt, pid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath+".dek", wrapped, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWALInclusive_RealSQLCipher_MergesUncheckpointedWAL is the genuine
|
||||
// end-to-end proof against the real C sqlcipher: an org shard carries user `z` in
|
||||
// its checkpointed main db and user `late` ONLY in its uncheckpointed -wal. The
|
||||
// DEFAULT (checkpointed) path migrates `z` but MISSES `late`; the --wal-inclusive
|
||||
// path recovers BOTH, and `late`'s golden argon2id digest verifies under the
|
||||
// clean verifier — the row a cutover built on DecryptFile alone would have lost.
|
||||
func TestWALInclusive_RealSQLCipher_MergesUncheckpointedWAL(t *testing.T) {
|
||||
bin := sqlcipherOrSkip(t)
|
||||
ctx := context.Background()
|
||||
master := randKey(t)
|
||||
datadir := t.TempDir()
|
||||
const env = "MIGRATE_V1_TEST_MASTER_KEY"
|
||||
t.Setenv(env, hex.EncodeToString(master))
|
||||
|
||||
// GLOBAL shard (C-written, checkpointed): one org.
|
||||
globalDB := filepath.Join(datadir, "iam.db")
|
||||
dekG := randKey(t)
|
||||
buildEncryptedDBViaC(t, bin, globalDB, dekG,
|
||||
`CREATE TABLE "organization"(owner text, name text, created_time text, password_type text);`+"\n"+
|
||||
`INSERT INTO "organization" VALUES('admin','hanzo','2020-01-02T03:04:05Z','argon2id');`+"\n")
|
||||
writeDEKSidecar(t, globalDB, master, hsqlite.PrincipalGlobal, globalPrincipalID, dekG)
|
||||
|
||||
// ORG shard (C-written): base user z (checkpointed) + user late (in -wal only).
|
||||
orgDB := filepath.Join(datadir, "orgs", "hanzo", "iam.db")
|
||||
dekO := randKey(t)
|
||||
buildEncryptedDBViaC(t, bin, orgDB, dekO,
|
||||
`CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, email text);`+"\n"+
|
||||
`INSERT INTO "user" VALUES('hanzo','z','2020-01-02T03:04:05Z','uuid-z','`+goldenDigest+`','argon2id','z@hanzo.ai');`+"\n")
|
||||
leaveRowsInWAL(t, bin, orgDB, dekO,
|
||||
`INSERT INTO "user" VALUES('hanzo','late','2020-03-03T03:04:05Z','uuid-late','`+goldenDigest+`','argon2id','late@hanzo.ai');`)
|
||||
writeDEKSidecar(t, orgDB, master, hsqlite.PrincipalOrg, "hanzo", dekO)
|
||||
|
||||
// ---- DEFAULT (checkpointed) path now HARD-FAILS on the shard's non-empty -wal
|
||||
// (HIGH #2): it would silently drop `late`, so with no flag it must refuse. ----
|
||||
if err := runEncrypted(ctx, datadir, env, t.TempDir(), t.TempDir(), false, nil, walMode{}); err == nil {
|
||||
t.Fatal("default path must refuse a shard carrying a non-empty uncheckpointed -wal")
|
||||
} else if !strings.Contains(err.Error(), "--wal-inclusive") || !strings.Contains(err.Error(), "--ignore-wal") {
|
||||
t.Errorf("hard-fail must name both escape hatches, got: %v", err)
|
||||
}
|
||||
|
||||
// ---- --ignore-wal: proceeds down the checkpointed path — migrates z, MISSES the
|
||||
// WAL-only late (the documented, opt-in lossy behavior). ----
|
||||
destCk := t.TempDir()
|
||||
if err := runEncrypted(ctx, datadir, env, t.TempDir(), destCk, false, nil, walMode{ignoreWAL: true}); err != nil {
|
||||
t.Fatalf("--ignore-wal checkpointed runEncrypted: %v", err)
|
||||
}
|
||||
dck, err := store.Open("sqlite", filepath.Join(destCk, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen checkpointed dest: %v", err)
|
||||
}
|
||||
defer dck.Close()
|
||||
if u, _ := store.GetUserByName(ctx, dck, "hanzo", "z"); u == nil {
|
||||
t.Fatal("--ignore-wal checkpointed path lost the base user z")
|
||||
}
|
||||
if late, _ := store.GetUserByName(ctx, dck, "hanzo", "late"); late != nil {
|
||||
t.Fatal("--ignore-wal path unexpectedly saw the WAL-only user — fixture WAL was already checkpointed")
|
||||
}
|
||||
|
||||
// ---- --wal-inclusive path: recovers BOTH, and late verifies golden. ----
|
||||
destWal := t.TempDir()
|
||||
workDir := t.TempDir()
|
||||
if err := runEncrypted(ctx, datadir, env, workDir, destWal, false, nil, walMode{enabled: true, bin: bin}); err != nil {
|
||||
t.Fatalf("wal-inclusive runEncrypted: %v", err)
|
||||
}
|
||||
dw, err := store.Open("sqlite", filepath.Join(destWal, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen wal-inclusive dest: %v", err)
|
||||
}
|
||||
defer dw.Close()
|
||||
if base, err := store.GetUserByName(ctx, dw, "hanzo", "z"); err != nil || base == nil {
|
||||
t.Fatalf("wal-inclusive path lost the base user z: %v", err)
|
||||
}
|
||||
late, err := store.GetUserByName(ctx, dw, "hanzo", "late")
|
||||
if err != nil || late == nil {
|
||||
t.Fatal("--wal-inclusive did NOT recover the uncheckpointed-WAL user — the fix is broken")
|
||||
}
|
||||
if late.PasswordHash != goldenDigest {
|
||||
t.Fatalf("recovered WAL user hash NOT verbatim:\n got %q\nwant %q", late.PasswordHash, goldenDigest)
|
||||
}
|
||||
if !cred.Verify(cred.Resolve(late.PasswordType, "argon2id"), goldenPassword, late.PasswordHash) {
|
||||
t.Fatal("cred.Verify REJECTED the WAL-recovered user's digest — that user could not log in at cutover")
|
||||
}
|
||||
if left, _ := os.ReadDir(workDir); len(left) != 0 {
|
||||
t.Errorf("work-dir not shredded after wal-inclusive run: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// assertDestEmpty fails if a dest store received any user (used after a run that
|
||||
// must abort before writing).
|
||||
func assertDestEmpty(t *testing.T, ctx context.Context, dest string) {
|
||||
t.Helper()
|
||||
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
|
||||
if err != nil {
|
||||
return // no store created at all is the strongest possible "empty"
|
||||
}
|
||||
defer dst.Close()
|
||||
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u != nil {
|
||||
t.Fatal("a failed --wal-inclusive run still wrote a user — must abort before any write")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ module github.com/hanzoai/iam
|
||||
|
||||
go 1.26.4
|
||||
|
||||
// Hanzo IAM v2 stack (MIGRATION.md §2) — no base, no consensus engine:
|
||||
// Hanzo IAM stack (MIGRATION.md §2) — no base, no consensus engine:
|
||||
// - github.com/zap-proto/zip — typed HTTP handlers on the zap-proto/fiber v3 engine
|
||||
// - github.com/hanzoai/orm — typed Go records over SQLite / hanzoai/sql / hanzoai/datastore
|
||||
require (
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
golang.org/x/crypto v0.53.0
|
||||
)
|
||||
|
||||
// Migration-only: linked solely in `go build -tags migration` so `iam2 compare`
|
||||
// Migration-only: linked solely in `go build -tags migration` so `iam compare`
|
||||
// can read the v1 Casdoor Postgres/MySQL database. The default (serving) build
|
||||
// never links these — it is SQLite/ZAP-only, no external SQL driver.
|
||||
require (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//go:build migration
|
||||
|
||||
// This file is linked only in `go build -tags migration`. It registers the v1
|
||||
// Casdoor database drivers (Postgres via pgx, MySQL) so `iam2 compare` can
|
||||
// Casdoor database drivers (Postgres via pgx, MySQL) so `iam compare` can
|
||||
// read the legacy store. The default build omits it, keeping the serving
|
||||
// binary free of any non-SQLite driver.
|
||||
package compare
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
package compare
|
||||
|
||||
// legacyDriver reports no driver in the default build. `iam2 compare` needs a
|
||||
// legacyDriver reports no driver in the default build. `iam compare` needs a
|
||||
// `-tags migration` build to link the v1 Postgres/MySQL driver — see
|
||||
// legacy_migration.go. This keeps the serving binary free of non-SQLite drivers.
|
||||
func legacyDriver(string) (string, bool) { return "", false }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package compat serves the Casdoor VERB surface (get-users, get-organizations,
|
||||
// …) over iam2's orm store, in the v1 Response envelope. It exists because every
|
||||
// …) over iam's orm store, in the v1 Response envelope. It exists because every
|
||||
// live consumer — the console admin BFF, the gateway admin-api, the hanzo.id
|
||||
// portal — hard-codes the Casdoor verb spellings and the `{status,data,data2}`
|
||||
// envelope, while iam2's native surface is REST (`/v1/iam/users`,
|
||||
// envelope, while iam's native surface is REST (`/v1/iam/users`,
|
||||
// `/v1/iam/users/get`). Without these aliases a backend swap 404s every console
|
||||
// IAM page. The aliases are a thin routing + envelope layer over the SAME orm
|
||||
// store and the SAME schema.Mask redaction the REST handlers use — no CRUD and
|
||||
@@ -155,7 +155,7 @@ func orgWorkspacesHandler(db orm.DB) zip.Handler {
|
||||
// paginates ONLY when BOTH `p` and `pageSize` are present — then the total rides
|
||||
// in data2; otherwise the full owner-scoped set is returned with no data2.
|
||||
//
|
||||
// Scoping note (intentional, fail-closed): iam2's ownership model is mixed —
|
||||
// Scoping note (intentional, fail-closed): iam's ownership model is mixed —
|
||||
// users/roles/permissions are owned by their tenant org, while organizations/
|
||||
// applications/providers/certs are platform-owned (Owner "admin"). A SuperAdmin
|
||||
// (Scope → the requested owner, empty = all) therefore lists every entity, which
|
||||
|
||||
+33
-11
@@ -75,7 +75,9 @@ func routeWrites(app *zip.App, db orm.DB) {
|
||||
|
||||
// Applications: add-/delete- (update-application already above).
|
||||
zip.Post(app, "/v1/iam/add-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) { return envelope(appCreate(ctx, in)) },
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
return envelope(appCreate(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("addApplication"), zip.WithSummary("Create an application (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
@@ -85,44 +87,64 @@ func routeWrites(app *zip.App, db orm.DB) {
|
||||
|
||||
// Providers: add-/update-/delete- (console admin Providers page).
|
||||
zip.Post(app, "/v1/iam/add-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provAdd(ctx, in)) },
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
|
||||
return envelope(provAdd(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("addProvider"), zip.WithSummary("Create a provider (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/update-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provUpdate(ctx, in)) },
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
|
||||
return envelope(provUpdate(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("updateProvider"), zip.WithSummary("Update a provider (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provDelete(ctx, in)) },
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
|
||||
return envelope(provDelete(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("deleteProvider"), zip.WithSummary("Delete a provider (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Roles: add-/update-/delete- (console admin Roles page).
|
||||
zip.Post(app, "/v1/iam/add-role",
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) { return envelope(rolesH.Create(ctx, in)) },
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) {
|
||||
return envelope(rolesH.Create(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("addRole"), zip.WithSummary("Create a role (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/update-role",
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) { return envelope(rolesH.Update(ctx, in)) },
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) {
|
||||
return envelope(rolesH.Update(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("updateRole"), zip.WithSummary("Update a role (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-role",
|
||||
func(ctx context.Context, in *roles.Ref) (*httpx.Response, error) { return envelope(rolesH.Delete(ctx, in)) },
|
||||
func(ctx context.Context, in *roles.Ref) (*httpx.Response, error) {
|
||||
return envelope(rolesH.Delete(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("deleteRole"), zip.WithSummary("Delete a role (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Projects: add-/delete- (console ScopeSwitcher; the read rides get-organization-projects
|
||||
// in aliases.go). Owner is the org, so app.Authorize gates a write to an org-admin
|
||||
// of that org — the same clause as add-role.
|
||||
zip.Post(app, "/v1/iam/add-project",
|
||||
func(ctx context.Context, in *projects.Input) (*httpx.Response, error) { return envelope(projectsH.Create(ctx, in)) },
|
||||
func(ctx context.Context, in *projects.Input) (*httpx.Response, error) {
|
||||
return envelope(projectsH.Create(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("addProject"), zip.WithSummary("Create a project (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-project",
|
||||
func(ctx context.Context, in *projects.Ref) (*httpx.Response, error) { return envelope(projectsH.Delete(ctx, in)) },
|
||||
func(ctx context.Context, in *projects.Ref) (*httpx.Response, error) {
|
||||
return envelope(projectsH.Delete(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("deleteProject"), zip.WithSummary("Delete a project (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Workspaces: add-/delete- (console ScopeSwitcher; the read rides
|
||||
// get-organization-workspaces in aliases.go). Owner is the org, so app.Authorize
|
||||
// gates a write to an org-admin of that org — the same clause as add-project.
|
||||
zip.Post(app, "/v1/iam/add-workspace",
|
||||
func(ctx context.Context, in *workspaces.Input) (*httpx.Response, error) { return envelope(workspacesH.Create(ctx, in)) },
|
||||
func(ctx context.Context, in *workspaces.Input) (*httpx.Response, error) {
|
||||
return envelope(workspacesH.Create(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("addWorkspace"), zip.WithSummary("Create a workspace (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-workspace",
|
||||
func(ctx context.Context, in *workspaces.Ref) (*httpx.Response, error) { return envelope(workspacesH.Delete(ctx, in)) },
|
||||
func(ctx context.Context, in *workspaces.Ref) (*httpx.Response, error) {
|
||||
return envelope(workspacesH.Delete(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("deleteWorkspace"), zip.WithSummary("Delete a workspace (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Organizations: update-/delete- (add-organization already above).
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// stamps it per user). A bcrypt-only verifier handed an argon2id PHC string
|
||||
// returns ErrHashTooShort, so a bcrypt-only login fails 100% of real users at
|
||||
// cutover. v1 resolves per row — user.PasswordType, falling back to the
|
||||
// organization's — and dispatches to the matching manager. iam2 does the same.
|
||||
// organization's — and dispatches to the matching manager. iam does the same.
|
||||
//
|
||||
// Hashing is argon2id ONLY (SOTA). Verify stays scheme-aware so pre-existing
|
||||
// bcrypt and v1 argon2id rows keep validating, but every NEW or updated digest
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// Supported password types. These are the two schemes Hanzo actually stores:
|
||||
// argon2id (every live v1 row) and bcrypt (what iam2 mints for new users).
|
||||
// argon2id (every live v1 row) and bcrypt (what iam mints for new users).
|
||||
// Anything else fails CLOSED — a silent "true" on an unrecognized scheme would
|
||||
// be an auth bypass, and a silent "false" we can't explain is a support
|
||||
// nightmare, so Verify reports Unsupported distinctly.
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// TestVerify_Argon2id_RealV1FormatHash is the regression for the cutover
|
||||
// blocker: every live v1 row is argon2id, and a bcrypt-only verifier fails all
|
||||
// of them. This proves iam2 verifies a genuine argon2id PHC digest — the exact
|
||||
// of them. This proves iam verifies a genuine argon2id PHC digest — the exact
|
||||
// shape v1's Argon2idCredManager writes (github.com/alexedwards/argon2id,
|
||||
// DefaultParams).
|
||||
func TestVerify_Argon2id_RealV1FormatHash(t *testing.T) {
|
||||
@@ -32,7 +32,7 @@ func TestVerify_Argon2id_RealV1FormatHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_BcryptStillWorks — new iam2-minted users are bcrypt; don't regress.
|
||||
// TestVerify_BcryptStillWorks — new iam-minted users are bcrypt; don't regress.
|
||||
func TestVerify_Bcrypt(t *testing.T) {
|
||||
pw := "s3cret-pw"
|
||||
h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
|
||||
|
||||
@@ -6,11 +6,11 @@ import "testing"
|
||||
|
||||
// Golden vectors: PHC digests produced by **v1's own Argon2idCredManager**
|
||||
// (hanzoai/iam `cred.NewArgon2idCredManager().GetHashedPassword`, DefaultParams),
|
||||
// captured verbatim. This is the parity proof that matters — iam2 must verify the
|
||||
// exact bytes v1 wrote, not merely a digest iam2 generated itself.
|
||||
// captured verbatim. This is the parity proof that matters — iam must verify the
|
||||
// exact bytes v1 wrote, not merely a digest iam generated itself.
|
||||
//
|
||||
// It also pins a REAL cross-version risk: v1 resolves
|
||||
// `github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387` while iam2
|
||||
// `github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387` while iam
|
||||
// pins `v1.0.0`. The PHC string is self-describing (m/t/p + salt + key), so a
|
||||
// digest from either version must verify under the other — this test is what
|
||||
// proves that, and what fails loudly if a future bump ever breaks it.
|
||||
@@ -25,10 +25,10 @@ const (
|
||||
)
|
||||
|
||||
// TestGolden_V1Argon2idDigestVerifies is the cutover-parity assertion: a digest
|
||||
// written by the LIVE v1 code path verifies under iam2's cred.Verify.
|
||||
// written by the LIVE v1 code path verifies under iam's cred.Verify.
|
||||
func TestGolden_V1Argon2idDigestVerifies(t *testing.T) {
|
||||
if !Verify(TypeArgon2id, goldenV1Password, goldenV1Digest) {
|
||||
t.Fatal("iam2 REJECTED a digest produced by v1's Argon2idCredManager — " +
|
||||
t.Fatal("iam REJECTED a digest produced by v1's Argon2idCredManager — " +
|
||||
"credential parity is broken; every live login would fail at cutover")
|
||||
}
|
||||
if Verify(TypeArgon2id, "not-the-password", goldenV1Digest) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package e2e_test drives the WHOLE iam2 surface through the real registered router
|
||||
// Package e2e_test drives the WHOLE iam surface through the real registered router
|
||||
// (routes.Route) as one integrated journey — the behavioral parity proof that the
|
||||
// old Casdoor IAM's clients work against iam2. Unlike the per-package unit tests,
|
||||
// old Casdoor IAM's clients work against iam. Unlike the per-package unit tests,
|
||||
// this chains the real flows a live client runs in sequence: OIDC discovery →
|
||||
// PKCE login → code→token → userinfo → introspect → revoke; the admin console's
|
||||
// get-account → get-organizations → get-users (the Casdoor compat surface); SCIM
|
||||
@@ -74,7 +74,7 @@ func boot(t *testing.T) *env {
|
||||
seedUser(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw", false)
|
||||
seedUser(t, db, "admin", "root", "root@hanzo.ai", "pw", true) // SuperAdmin
|
||||
|
||||
app := zip.New(zip.Config{AppName: "iam2-e2e", DisableStartupMessage: true})
|
||||
app := zip.New(zip.Config{AppName: "iam-e2e", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
return &env{app: app, key: key, db: db}
|
||||
@@ -158,8 +158,8 @@ func TestJourney_PasswordGrant_and_TokenExchange(t *testing.T) {
|
||||
// RFC 8693 token exchange: the BFF exchanges alice's token for one scoped to a
|
||||
// downstream resource, still bound to alice.
|
||||
xe := e.token(t, url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"},
|
||||
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"},
|
||||
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"subject_token": {subjectToken}, "resource": {"hanzo-cloud"},
|
||||
})
|
||||
if xe["issued_token_type"] != "urn:ietf:params:oauth:token-type:access_token" || xe["access_token"] == "" {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package featurestore implements feature.Store over the iam2 orm store, so the
|
||||
// Package featurestore implements feature.Store over the iam orm store, so the
|
||||
// hanzoiam/* enterprise modules read/write the SAME identity data as the core.
|
||||
// Internal: the core (server.Route) constructs it and hands the interface to
|
||||
// feature.RouteAll — modules never see this package, only the feature.Store seam.
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// The authorization endpoint: GET/POST /v1/iam/oauth/authorize — the front door
|
||||
// of the authorization-code flow. iam2 validates the request BEFORE it trusts
|
||||
// of the authorization-code flow. iam validates the request BEFORE it trusts
|
||||
// any redirect: an unknown client_id or an unregistered redirect_uri is answered
|
||||
// in place and NEVER redirected to (RFC 6749 §4.1.2.1), closing the open-redirect
|
||||
// and code-injection surface that a bare pass-through would leave open. A
|
||||
@@ -165,7 +165,7 @@ func authorizeUserError(c *zip.Ctx, msg string) error {
|
||||
}
|
||||
|
||||
// normalizeChallengeMethod maps an omitted PKCE method to S256 when a challenge
|
||||
// is present (S256 is the only method iam2 supports); an explicit non-S256
|
||||
// is present (S256 is the only method iam supports); an explicit non-S256
|
||||
// method is returned unchanged so the caller rejects the downgrade.
|
||||
func normalizeChallengeMethod(challenge, method string) string {
|
||||
if challenge == "" {
|
||||
|
||||
@@ -22,9 +22,10 @@ import (
|
||||
// SELF-SCOPED: the target is ALWAYS the caller (callerOf), never a body field.
|
||||
//
|
||||
// Two switches, privacy-first defaults when unset:
|
||||
// insights default TRUE — anonymous product usage (no query/answer text).
|
||||
// shareTraining default FALSE — OPT-IN to contribute the user's own data to
|
||||
// train Hanzo's open models.
|
||||
//
|
||||
// insights default TRUE — anonymous product usage (no query/answer text).
|
||||
// shareTraining default FALSE — OPT-IN to contribute the user's own data to
|
||||
// train Hanzo's open models.
|
||||
const PathConsent = "/v1/iam/consent"
|
||||
|
||||
// consentKey nests the consent object inside the preferences blob.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// Discovery is served at both well-known paths, host-relative, advertising only
|
||||
// what iam2 implements — matching the live hanzo.id surface so a client's
|
||||
// what iam implements — matching the live hanzo.id surface so a client's
|
||||
// discovery step is unchanged across the backend swap.
|
||||
func TestDiscovery_ShapeAtBothPaths(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
|
||||
@@ -23,14 +23,14 @@ import (
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
)
|
||||
|
||||
// Identity federation — iam2 as an OIDC/OAuth2 Relying Party to external IdPs.
|
||||
// Identity federation — iam as an OIDC/OAuth2 Relying Party to external IdPs.
|
||||
//
|
||||
// A social sign-in is a DETOUR inside the ordinary authorization-code flow. The
|
||||
// authorize endpoint, having already validated the client and its EXACT
|
||||
// redirect_uri (so there is a trusted target before anything is trusted), hands
|
||||
// a request that names a `provider` to beginFederation, which stashes the whole
|
||||
// app-leg request server-side and sends the browser to the IdP. When the IdP
|
||||
// returns to the fixed callback, iam2 verifies the response, LINKS or PROVISIONS
|
||||
// returns to the fixed callback, iam verifies the response, LINKS or PROVISIONS
|
||||
// a local user, and mints ITS OWN authorization code — bound to the original
|
||||
// PKCE challenge, redirect_uri, and nonce — exactly as a password login would.
|
||||
// The relying party's existing PKCE code→token exchange then completes unchanged.
|
||||
@@ -41,7 +41,7 @@ import (
|
||||
|
||||
// PathFederationCallback is the fixed IdP return endpoint. One callback for every
|
||||
// provider — the provider is recovered from the server-side transaction the
|
||||
// state keys, never from a spoofable URL segment. It is the redirect_uri iam2
|
||||
// state keys, never from a spoofable URL segment. It is the redirect_uri iam
|
||||
// registers with each external IdP.
|
||||
const PathFederationCallback = "/v1/iam/oauth/callback"
|
||||
|
||||
@@ -152,7 +152,7 @@ func beginFederation(c *zip.Ctx, db orm.DB, app *schema.Application, q authorize
|
||||
// federationCallbackHandler completes the round-trip: it resolves and burns the
|
||||
// single-use transaction (checking expiry + browser binding), exchanges and
|
||||
// verifies the IdP response, links or provisions the local user, and mints the
|
||||
// iam2 authorization code the relying party expects — then redirects to the
|
||||
// iam authorization code the relying party expects — then redirects to the
|
||||
// original redirect_uri with code + state.
|
||||
func federationCallbackHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
@@ -277,7 +277,7 @@ func federationCallbackHandler(db orm.DB) zip.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// fedResumeParams is the ORIGINAL iam2 authorize request, pinned server-side so
|
||||
// fedResumeParams is the ORIGINAL iam authorize request, pinned server-side so
|
||||
// the code minted after a federated login (immediately, or after a second factor)
|
||||
// binds to exactly these values — never to anything a later request supplies.
|
||||
type fedResumeParams struct {
|
||||
@@ -295,7 +295,7 @@ type fedResumeParams struct {
|
||||
// invalid_request; every other mint failure is an opaque server_error.
|
||||
var errPKCERequired = errors.New("federation: PKCE is required for public clients")
|
||||
|
||||
// federationMint mints iam2's own authorization code — the SAME artifact a
|
||||
// federationMint mints iam's own authorization code — the SAME artifact a
|
||||
// password login mints — bound to the pinned app-leg PKCE, redirect_uri and nonce,
|
||||
// and returns the RP redirect (redirect_uri?code&state). It is the ONE mint path
|
||||
// both the no-factor completion and the post-2FA resume reach, so a federated code
|
||||
@@ -461,7 +461,7 @@ func providerOwner(p *schema.Provider) string {
|
||||
return "admin"
|
||||
}
|
||||
|
||||
// federationCallbackURL is the iam2 callback iam2 registers with the IdP and
|
||||
// federationCallbackURL is the iam callback iam registers with the IdP and
|
||||
// re-presents at the token exchange. It is PINNED from config, never steered by a
|
||||
// request header, so an attacker cannot redirect the IdP leg via X-Forwarded-Host.
|
||||
func federationCallbackURL(c *zip.Ctx) string {
|
||||
@@ -511,7 +511,7 @@ func federationOrgAllowed(app *schema.Application) bool {
|
||||
}
|
||||
|
||||
// fedSuccessRedirect returns the browser to the relying party's redirect_uri with
|
||||
// the iam2 authorization code and the original app state (RFC 6749 §4.1.2).
|
||||
// the iam authorization code and the original app state (RFC 6749 §4.1.2).
|
||||
func fedSuccessRedirect(c *zip.Ctx, st *schema.FederationState, code string) error {
|
||||
v := url.Values{}
|
||||
v.Set("code", code)
|
||||
@@ -573,7 +573,7 @@ type connectorBinding struct {
|
||||
// name is the EXACT json/orm name (already lowercase): orm's filter lowercases
|
||||
// only the FIRST rune, so a Go field name like "GitHub" would query '$.gitHub'
|
||||
// (the tag is 'github') — passing the exact json name is the one correct way, and
|
||||
// this registry is its single source of truth. Only the connectors iam2 can
|
||||
// this registry is its single source of truth. Only the connectors iam can
|
||||
// federate are listed; anything else fails closed.
|
||||
var connectorRegistry = map[string]connectorBinding{
|
||||
"google": {"google", func(u *schema.User) *string { return &u.Google }},
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// The Relying-Party side of federation: iam2 as an OIDC/OAuth2 CLIENT of an
|
||||
// The Relying-Party side of federation: iam as an OIDC/OAuth2 CLIENT of an
|
||||
// external identity provider. Two dialects, one contract (federatedIdentity):
|
||||
//
|
||||
// - OIDC (Google + any provider with an IssuerUrl): OIDC Discovery resolves the
|
||||
@@ -63,7 +63,7 @@ type federatedIdentity struct {
|
||||
// SSRF-via-redirect vector; and the dialer Control refuses to connect to a
|
||||
// private/loopback/link-local/metadata address AT DIAL TIME — after DNS
|
||||
// resolution, on the ACTUAL connecting IP — so a hostile IssuerUrl/Custom*Url (or
|
||||
// a DNS-rebinding hostname) cannot make iam2 reach an internal service or the
|
||||
// a DNS-rebinding hostname) cannot make iam reach an internal service or the
|
||||
// cloud metadata endpoint.
|
||||
var federationHTTPClient = &http.Client{
|
||||
Timeout: 12 * time.Second,
|
||||
@@ -112,7 +112,7 @@ func federationDialControl(_, address string, _ syscall.RawConn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipBlockedForFederation reports whether an IP is in a range iam2 must never
|
||||
// ipBlockedForFederation reports whether an IP is in a range iam must never
|
||||
// fetch from during federation. net.IP.IsPrivate covers RFC1918 and IPv6 ULA
|
||||
// (fc00::/7); IsLinkLocalUnicast covers 169.254.0.0/16 (incl. the 169.254.169.254
|
||||
// cloud-metadata address) and fe80::/10.
|
||||
@@ -160,7 +160,7 @@ func idpKind(p *schema.Provider) string {
|
||||
}
|
||||
|
||||
// idpAuthorizeURL builds the IdP authorization-endpoint URL the browser is sent
|
||||
// to at the begin leg — dialect-dispatched, with iam2's callback as the IdP
|
||||
// to at the begin leg — dialect-dispatched, with iam's callback as the IdP
|
||||
// redirect_uri, our single-use state, IdP-leg PKCE, and (OIDC) the nonce.
|
||||
func idpAuthorizeURL(ctx context.Context, p *schema.Provider, st *schema.FederationState, callback string) (string, error) {
|
||||
switch idpKind(p) {
|
||||
@@ -239,7 +239,7 @@ func oidcResolve(ctx context.Context, p *schema.Provider) (oidcConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// oidcDiscoveryDocument is the subset of the OIDC Discovery document iam2 reads.
|
||||
// oidcDiscoveryDocument is the subset of the OIDC Discovery document iam reads.
|
||||
type oidcDiscoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
@@ -323,7 +323,7 @@ func oidcExchange(ctx context.Context, cfg oidcConfig, p *schema.Provider, st *s
|
||||
}, nil
|
||||
}
|
||||
|
||||
// idTokenClaims is the id_token claim set iam2 reads. Nonce is a top-level OIDC
|
||||
// idTokenClaims is the id_token claim set iam reads. Nonce is a top-level OIDC
|
||||
// claim (not a registered JWT claim), verified against the transaction's stored
|
||||
// nonce. email_verified is `any` because providers send it as a JSON bool or
|
||||
// (legacy) the string "true".
|
||||
@@ -391,7 +391,7 @@ type githubTokenResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// githubUser / githubEmail are the userinfo shapes iam2 reads.
|
||||
// githubUser / githubEmail are the userinfo shapes iam reads.
|
||||
type githubUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
@@ -528,7 +528,7 @@ func newIdPRequest(ctx context.Context, method, rawURL string, body io.Reader, h
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "hanzo-iam2-federation")
|
||||
req.Header.Set("User-Agent", "hanzo-iam-federation")
|
||||
for k, vs := range header {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
@@ -588,7 +588,7 @@ func isLoopbackHost(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// jwkSet / jwk are the JSON Web Key Set shapes iam2 verifies id_tokens against.
|
||||
// jwkSet / jwk are the JSON Web Key Set shapes iam verifies id_tokens against.
|
||||
type jwkSet struct {
|
||||
Keys []jwk `json:"keys"`
|
||||
}
|
||||
@@ -631,7 +631,7 @@ func jwksKeyfunc(ctx context.Context, jwksURL string) jwt.Keyfunc {
|
||||
}
|
||||
|
||||
// publicKey materializes a JWK into a crypto public key (RSA or EC). Only the
|
||||
// two families iam2 signs with are supported; any other key type is refused.
|
||||
// two families iam signs with are supported; any other key type is refused.
|
||||
func (k jwk) publicKey() (any, error) {
|
||||
switch k.Kty {
|
||||
case "RSA":
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// Federation is driven through the REAL registered routes (authorize → IdP → callback
|
||||
// → code → token). The external IdP is an httptest server — a real HTTP RP round
|
||||
// trip with a real OIDC discovery document, a real JWKS, and a real RS256-signed
|
||||
// id_token whose signature/issuer/audience/nonce iam2 actually verifies (Google
|
||||
// id_token whose signature/issuer/audience/nonce iam actually verifies (Google
|
||||
// dialect), plus a real GitHub userinfo + verified-email exchange. No live
|
||||
// Google/GitHub is contacted.
|
||||
|
||||
@@ -375,7 +375,7 @@ func TestFederation_AuthorizeRedirectsToGitHub(t *testing.T) {
|
||||
}
|
||||
|
||||
// Full OIDC round-trip: a first-time login PROVISIONS a user (no password, not
|
||||
// admin) and mints an iam2 authorization code; the relying party's existing PKCE
|
||||
// admin) and mints an iam authorization code; the relying party's existing PKCE
|
||||
// code→token exchange then completes unchanged and carries the new user's sub.
|
||||
func TestFederation_OIDCCallbackProvisionsUserAndIssuesCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
@@ -394,7 +394,7 @@ func TestFederation_OIDCCallbackProvisionsUserAndIssuesCode(t *testing.T) {
|
||||
cb, _ := url.Parse(loc)
|
||||
code := cb.Query().Get("code")
|
||||
if code == "" {
|
||||
t.Fatalf("callback must redirect with an iam2 code; got %q", loc)
|
||||
t.Fatalf("callback must redirect with an iam code; got %q", loc)
|
||||
}
|
||||
if cb.Query().Get("state") != fedAppState {
|
||||
t.Errorf("app state not echoed: %q", cb.Query().Get("state"))
|
||||
@@ -418,19 +418,19 @@ func TestFederation_OIDCCallbackProvisionsUserAndIssuesCode(t *testing.T) {
|
||||
t.Fatalf("expected exactly one new user")
|
||||
}
|
||||
|
||||
// The iam2 code redeems through the ordinary PKCE token exchange, unchanged.
|
||||
// The iam code redeems through the ordinary PKCE token exchange, unchanged.
|
||||
tokResp, tok := exchangeCode(t, app, url.Values{
|
||||
"code": {code}, "client_id": {"webapp"}, "redirect_uri": {testRedirect}, "code_verifier": {fedVerifier},
|
||||
})
|
||||
if tokResp.StatusCode != 200 {
|
||||
t.Fatalf("iam2 code exchange failed: %d %v", tokResp.StatusCode, tok)
|
||||
t.Fatalf("iam code exchange failed: %d %v", tokResp.StatusCode, tok)
|
||||
}
|
||||
if tok["access_token"] == nil {
|
||||
t.Fatal("no access_token from the iam2 code exchange")
|
||||
t.Fatal("no access_token from the iam code exchange")
|
||||
}
|
||||
// The token subject is the provisioned user; no IdP token leaks into it.
|
||||
if body := tokenBody(tok); strings.Contains(body, "idp-at-") || strings.Contains(body, "google-secret-do-not-log") {
|
||||
t.Fatal("IdP access token / client secret leaked into the iam2 token response")
|
||||
t.Fatal("IdP access token / client secret leaked into the iam token response")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ func TestFederation_GitHubCallbackProvisionsViaVerifiedEmail(t *testing.T) {
|
||||
resp := callback(t, app, q.Get("state"), "gh-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if code := mustQuery(t, loc).Get("code"); code == "" {
|
||||
t.Fatalf("GitHub federation did not mint an iam2 code: %q", loc)
|
||||
t.Fatalf("GitHub federation did not mint an iam code: %q", loc)
|
||||
}
|
||||
u, err := store.GetUserByConnector(context.Background(), db, "hanzo", "github", "424242")
|
||||
if err != nil || u == nil {
|
||||
@@ -710,7 +710,7 @@ func TestFederation_NonAllowlistedRedirectUriRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
// runOIDCLogin drives a full successful OIDC federation login (authorize →
|
||||
// callback) and asserts it lands an iam2 code. mutate may tweak the mock after
|
||||
// callback) and asserts it lands an iam code. mutate may tweak the mock after
|
||||
// the nonce is bound.
|
||||
func runOIDCLogin(t *testing.T, app *zip.App, db orm.DB, m *mockOIDC, clientID string, mutate func()) {
|
||||
t.Helper()
|
||||
@@ -724,7 +724,7 @@ func runOIDCLogin(t *testing.T, app *zip.App, db orm.DB, m *mockOIDC, clientID s
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-"+randHex(3), cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if mustQuery(t, loc).Get("code") == "" {
|
||||
t.Fatalf("federation login did not mint an iam2 code: %q", loc)
|
||||
t.Fatalf("federation login did not mint an iam code: %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func newUnlinkServer(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
app := zip.New(zip.Config{AppName: "iam2-unlink-test", DisableStartupMessage: true})
|
||||
app := zip.New(zip.Config{AppName: "iam-unlink-test", DisableStartupMessage: true})
|
||||
Route(app.Group(""), db) // public: authorize/login/token AND the self-authenticating unlink
|
||||
return app, db
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func tctx() context.Context { return context.Background() }
|
||||
func newServer(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
|
||||
app := zip.New(zip.Config{AppName: "iam-test", DisableStartupMessage: true})
|
||||
// The whole OIDC surface is the pre-authentication PUBLIC group; a root
|
||||
// (empty-prefix) router registers it at its absolute paths, no Guard.
|
||||
Route(app.Group(""), db)
|
||||
|
||||
@@ -7,19 +7,19 @@ import (
|
||||
)
|
||||
|
||||
// The port of the iam-v1 fixes c904dc0a + 0e5485a5 ("app principal can't
|
||||
// impersonate its way to admin/super via ?userId") into the iam2 architecture.
|
||||
// impersonate its way to admin/super via ?userId") into the iam architecture.
|
||||
//
|
||||
// iam-v1 let an "app/<name>" confidential client set ?userId=<owner>/<name> on the
|
||||
// identity endpoints and RequireAdmin routes, then derived admin/super authority
|
||||
// from the RESOLVED user — so ?userId=admin/z made the app a platform SuperAdmin.
|
||||
//
|
||||
// iam2 has NO ?userId override anywhere: userinfo/whoami/get-account take the
|
||||
// iam has NO ?userId override anywhere: userinfo/whoami/get-account take the
|
||||
// subject from the VERIFIED JWT `sub` (oidc.callerOf / splitSub(claims.Subject)),
|
||||
// and an app principal is never Admin/Super — its whole authority is its capability
|
||||
// allowlist (authz.app / authz.authorize: `if p.App != "" { return Allowed(...) }`).
|
||||
// The one surface where a confidential client acts on an ARBITRARY named user is the
|
||||
// on-behalf-of mint (issue-user-token / mint-user-keys), targeted by ?id=<owner>/<name>.
|
||||
// That is the iam2 analogue of iam-v1's ?userId, and the escalation-blocking guard is
|
||||
// That is the iam analogue of iam-v1's ?userId, and the escalation-blocking guard is
|
||||
// mintTarget's reserved-org gate (issuetoken.go):
|
||||
//
|
||||
// if store.IsSigningCertOwner(owner) && !adminMintAllowed(clientApp) { return 403 }
|
||||
|
||||
@@ -29,7 +29,7 @@ var errNoIssuer = errors.New(
|
||||
|
||||
// The per-host OIDC issuer resolver.
|
||||
//
|
||||
// iam2 runs as ONE multi-tenant instance behind the ingress for every brand host
|
||||
// iam runs as ONE multi-tenant instance behind the ingress for every brand host
|
||||
// — hanzo.id, lux.id, id.zoo.network, pars.id, and their iam.* aliases. Each
|
||||
// brand must emit its OWN issuer so a relying party that discovered via lux.id
|
||||
// validates lux.id-issued tokens (`iss` is the boundary an RP pins). A single
|
||||
@@ -45,7 +45,7 @@ var errNoIssuer = errors.New(
|
||||
// That is the "header-immune issuer" property, preserved and generalized to N
|
||||
// brands.
|
||||
|
||||
// issuerResolver maps a brand host to the OIDC issuer iam2 emits for it. It is
|
||||
// issuerResolver maps a brand host to the OIDC issuer iam emits for it. It is
|
||||
// built ONCE from config (IAM_ISSUER + IAM_ISSUER_MAP) and is immutable
|
||||
// thereafter — a value, not a place, so every request goroutine reads it without
|
||||
// a lock and no request can mutate it.
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
// testIssuerMap is the canonical multi-brand map the cutover deploy configures as
|
||||
// IAM_ISSUER_MAP: several ingress hosts (including iam.* aliases) collapse to ONE
|
||||
// pinned issuer per brand, all served by the single iam2 instance.
|
||||
// pinned issuer per brand, all served by the single iam instance.
|
||||
const testIssuerMap = `{
|
||||
"hanzo.id": "https://hanzo.id",
|
||||
"iam.hanzo.ai": "https://hanzo.id",
|
||||
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
)
|
||||
|
||||
// The JSON Web Key Set: the public half of every active signing Cert, so relying
|
||||
// parties verify the tokens iam2 issues. This is the load-bearing interop
|
||||
// parties verify the tokens iam issues. This is the load-bearing interop
|
||||
// surface — the live hanzo.id JWKS publishes one RSA (RS256) key per Cert, keyed
|
||||
// by `kid` = the Cert name, and every existing verifier reads it. Keys are
|
||||
// deduplicated by kid and ordered stably; the response carries a strong ETag and
|
||||
// a 60s cache, matching live.
|
||||
|
||||
// signingAlgs is the set of JOSE algorithms iam2 publishes signing keys for.
|
||||
// signingAlgs is the set of JOSE algorithms iam publishes signing keys for.
|
||||
// A Cert whose CryptoAlgorithm is outside this set (e.g. an ACME/SSL TLS cert)
|
||||
// is not a token-signing key and is excluded from the JWKS.
|
||||
var signingAlgs = map[string]bool{
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// inert until an ML-DSA Cert is configured. Keys come from the Cert entity
|
||||
// (KMS-backed); tests inject an ephemeral in-memory key through the same path.
|
||||
|
||||
// Claims is the iam2 token claim set: the standard registered claims plus the
|
||||
// Claims is the iam token claim set: the standard registered claims plus the
|
||||
// Hanzo first-class claims the SDK and downstream validators read. owner and
|
||||
// organization are the tenant (both the org slug); scope carries the granted
|
||||
// scopes; nonce is echoed into the id_token; tokenType distinguishes an
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// subject — the casdoor data model. So the linked accounts ARE those per-connector
|
||||
// columns that are set; this returns [{provider, subject}] for each non-empty one.
|
||||
// Each item carries only the subject string (the schema stores no per-link display
|
||||
// name / avatar / linkedAt), so a richer per-link shape is not available from iam2.
|
||||
// name / avatar / linkedAt), so a richer per-link shape is not available from iam.
|
||||
// Self-scoped: resolved from the caller (callerOf), never the request.
|
||||
|
||||
// PathLinkedAccounts is the canonical linked-identities endpoint.
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// same db the router serves (newServer opens its own).
|
||||
func newApp(t *testing.T, db orm.DB) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
|
||||
app := zip.New(zip.Config{AppName: "iam-test", DisableStartupMessage: true})
|
||||
// The OIDC surface is the pre-auth PUBLIC group; login + the challenge finish
|
||||
// both live here, so a root (empty-prefix) router registers them at their absolute
|
||||
// paths (main renamed Route→Route on the zip-group model).
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The end-session endpoint: GET/POST /v1/iam/oauth/logout. iam2 holds no
|
||||
// The end-session endpoint: GET/POST /v1/iam/oauth/logout. iam holds no
|
||||
// server-side browser session to destroy here, so logout's security-relevant
|
||||
// job is the redirect: it bounces to post_logout_redirect_uri ONLY when that URI
|
||||
// is registered by the client named in a signature-verified id_token_hint —
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package oidc serves the IAM v2 OpenID Connect / OAuth2 surface on zip. The
|
||||
// Package oidc serves the IAM OpenID Connect / OAuth2 surface on zip. The
|
||||
// handlers are RAW zip handlers (func(c *zip.Ctx) error), not typed generics,
|
||||
// because the auth surface needs query params, form bodies, redirects, and
|
||||
// headers a JSON-in/JSON-out handler can't reach.
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// Canonical OIDC paths — the single source of truth the @hanzo/iam SDK and every
|
||||
// existing relying party hard-code. iam2 serves them directly; the transition
|
||||
// existing relying party hard-code. iam serves them directly; the transition
|
||||
// off v1 is a backend swap behind the same paths, never a parallel version.
|
||||
const (
|
||||
PathAuthorize = "/v1/iam/oauth/authorize"
|
||||
@@ -100,7 +100,7 @@ func Route(r zip.Router, db orm.DB) {
|
||||
|
||||
// Discovery serves the OIDC discovery document, host-relative (issuer derived
|
||||
// from the request host, the same value the tokens carry as `iss`) so a strict
|
||||
// client never splits origin. It advertises only what iam2 implements: the
|
||||
// client never splits origin. It advertises only what iam implements: the
|
||||
// authorization-code flow, S256 PKCE, the three supported grants, and the
|
||||
// signing algorithms whose public keys the JWKS actually publishes.
|
||||
func Discovery(c *zip.Ctx) error {
|
||||
|
||||
@@ -51,7 +51,7 @@ const (
|
||||
// reserved set never drifts between surfaces. admin is here for a second reason: it is
|
||||
// the reserved SuperAdmin org, and a self-service signup must never provision into it
|
||||
// (provision, do not promote). Brand/staff orgs (hanzo/lux/zoo/pars in the console
|
||||
// list) are NOT reserved here (iam2 is white-label): an existing one is refused by the
|
||||
// list) are NOT reserved here (iam is white-label): an existing one is refused by the
|
||||
// create-conflict check.
|
||||
|
||||
// onboardForm is the request body: a name to create, or personal=true for the
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// PKCE (RFC 7636) — S256 only. iam2 permanently rejects the "plain" method:
|
||||
// PKCE (RFC 7636) — S256 only. iam permanently rejects the "plain" method:
|
||||
// a downgrade to plain defeats the point of PKCE (the verifier travels in the
|
||||
// clear), so an authorize request that stored a plain challenge, or a token
|
||||
// request that presents one, is refused.
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
// This endpoint owns the code-generation + persistence + validation surface. The
|
||||
// actual email/SMS DELIVERY is a separate concern owned by hanzoai/notify (v1
|
||||
// calls object.SendVerificationCodeToEmail/…Phone, which forwards to notify over
|
||||
// ZAP). notify is not bound into iam2 yet, so this endpoint persists a verifiable
|
||||
// ZAP). notify is not bound into iam yet, so this endpoint persists a verifiable
|
||||
// code and returns {status:"ok"} honestly — it does NOT fabricate a "sent" claim.
|
||||
// Delivery plugs in at the marked seam below with no shape change.
|
||||
|
||||
@@ -46,9 +46,9 @@ const verificationCodeTTL = 10 * time.Minute
|
||||
// reports success. The request fields are read via fiber's FormValue — the
|
||||
// escape hatch zip exposes for form bodies (multipart or urlencoded) — since the
|
||||
// typed JSON Bind does not apply here. v1 also accepts countryCode/method/
|
||||
// checkUser/captchaType; iam2 ignores them (the captcha/forget/MFA flows those
|
||||
// checkUser/captchaType; iam ignores them (the captcha/forget/MFA flows those
|
||||
// drive are not ported), and CAPTCHA verification is likewise not enforced —
|
||||
// iam2 models no captcha provider — so the code is issued once the destination
|
||||
// iam models no captcha provider — so the code is issued once the destination
|
||||
// and application validate.
|
||||
func sendVerificationCode(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
@@ -89,7 +89,7 @@ func sendVerificationCode(db orm.DB) zip.Handler {
|
||||
|
||||
// Validate the destination by type and, for email, resolve the target user
|
||||
// (metadata on the record). Phone user-resolution + E.164 normalization need
|
||||
// a phone library iam2 does not carry yet — the record still persists.
|
||||
// a phone library iam does not carry yet — the record still persists.
|
||||
var user *schema.User
|
||||
switch typ {
|
||||
case "email":
|
||||
@@ -136,7 +136,7 @@ func sendVerificationCode(db orm.DB) zip.Handler {
|
||||
// v1 hands (org, user, dest, code) to hanzoai/notify here
|
||||
// (object.SendVerificationCodeToEmail / …ToPhone). notify owns the
|
||||
// per-tenant SendGrid/SMTP/Resend/Twilio provider + template. It is not
|
||||
// bound into iam2 yet; when it is, the send call slots in exactly here and
|
||||
// bound into iam yet; when it is, the send call slots in exactly here and
|
||||
// the persisted record above stays the source of truth for verification.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
// The code→session exchange: POST /v1/iam/signin. After the authorize/login flow
|
||||
// redirects back with `?code&state`, the console posts them here (code+state ride
|
||||
// the query — what the @hanzo/iam client sends — or a JSON body) and iam2 redeems
|
||||
// the query — what the @hanzo/iam client sends — or a JSON body) and iam redeems
|
||||
// the code for a durable SESSION, returning the account envelope like get-account.
|
||||
//
|
||||
// This is the session-establishment counterpart to the OAuth token endpoint: the
|
||||
|
||||
@@ -65,7 +65,7 @@ func signupHandler(db orm.DB) zip.Handler {
|
||||
}
|
||||
|
||||
// Resolve the application (by clientId when present, else by name under the
|
||||
// admin owner — the iam2 storage convention), then enforce its policy.
|
||||
// admin owner — the iam storage convention), then enforce its policy.
|
||||
app, err := resolveSignupApp(ctx, db, f)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
@@ -159,7 +159,7 @@ func signupHandler(db orm.DB) zip.Handler {
|
||||
// Create through the ONE canonical user path (users.Create): argon2id-hash the
|
||||
// password once, persist, return the REDACTED row (no plaintext, no digest ever
|
||||
// stored or returned). PasswordType is stamped "argon2id" — exactly what
|
||||
// internal/cred verifies for a new iam2 row.
|
||||
// internal/cred verifies for a new iam row.
|
||||
created, err := users.New(db).Create(ctx, &users.CreateInput{
|
||||
User: schema.User{
|
||||
Owner: f.Organization,
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// fullApp gives a test full control over the signup-relevant application fields the
|
||||
// shared harness seedApp fixes (it hardcodes Organization "hanzo" and exposes only
|
||||
// IsShared/EnableSignUp). Owner is "admin" (platform-owned, the iam2 convention);
|
||||
// IsShared/EnableSignUp). Owner is "admin" (platform-owned, the iam convention);
|
||||
// clientId == Name (the <org>-<app> convention the mint allow-lists key on).
|
||||
type fullApp struct {
|
||||
clientID string
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// The token endpoint: POST /v1/iam/oauth/token. It dispatches the three grant
|
||||
// types iam2 issues — authorization_code (PKCE-verified, single-use code →
|
||||
// types iam issues — authorization_code (PKCE-verified, single-use code →
|
||||
// access JWT + id_token when openid + rotating refresh), refresh_token
|
||||
// (rotation with reuse detection, refresh.go), and client_credentials
|
||||
// (machine-to-machine, no user, no refresh). Every response carries no-store
|
||||
@@ -254,7 +254,7 @@ func clientCredentialsGrant(c *zip.Ctx, db orm.DB) error {
|
||||
//
|
||||
// CASDOOR PARITY — INTENTIONAL SECURITY-POSTURE DECISION (flagged for Red review).
|
||||
// Casdoor ALLOWS a PUBLIC client (the console/chat apps: no client_secret, no PKCE)
|
||||
// to complete this grant. During the casdoor→clean-room cutover iam2 defaults to
|
||||
// to complete this grant. During the casdoor→clean-room cutover iam defaults to
|
||||
// the SAME behavior so console/chat logins do not 401 `invalid_client`. The exact,
|
||||
// bounded relaxation vs the prior confidential-only rule:
|
||||
//
|
||||
@@ -487,7 +487,7 @@ func refreshTTL(app *schema.Application) time.Duration {
|
||||
// signerFor loads the application's signing cert from the trusted platform
|
||||
// signing-cert owners and builds a Signer with the given canonical issuer. Using
|
||||
// the same trusted resolution as the JWKS and verification keeps the three
|
||||
// consistent: a token is signed by a key iam2 will also publish and verify.
|
||||
// consistent: a token is signed by a key iam will also publish and verify.
|
||||
func signerFor(ctx context.Context, db orm.DB, app *schema.Application, issuer string) (*Signer, error) {
|
||||
cert, err := store.GetSigningCert(ctx, db, app.Cert)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,7 @@ func openTestDB(t *testing.T) orm.DB {
|
||||
_ = schema.Kinds() // force the schema package init() (kind registration)
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "iam2test.db"),
|
||||
Path: filepath.Join(dir, "iamtest.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestAuthCodeFlow_ConfidentialHappyPath(t *testing.T) {
|
||||
t.Fatalf("token response missing fields: %v", tok)
|
||||
}
|
||||
|
||||
// The access token verifies through iam2's own verify path with the right
|
||||
// The access token verifies through iam's own verify path with the right
|
||||
// issuer, audience, subject, and tenant.
|
||||
access := tok["access_token"].(string)
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
@@ -131,7 +131,7 @@ func TestToken_ErrorTaxonomy(t *testing.T) {
|
||||
requireError(t, resp, tok, 400, "invalid_request")
|
||||
})
|
||||
t.Run("unsupported grant_type", func(t *testing.T) {
|
||||
// A grant iam2 does not implement (RFC 7523 jwt-bearer) — device_code and
|
||||
// A grant iam does not implement (RFC 7523 jwt-bearer) — device_code and
|
||||
// password ARE supported now.
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}})
|
||||
requireError(t, resp, tok, 400, "unsupported_grant_type")
|
||||
|
||||
@@ -109,7 +109,7 @@ func newServer(t *testing.T) (*zip.App, orm.DB, *keyring) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
kr := testKeyring(t)
|
||||
app := zip.New(zip.Config{AppName: "iam2-registry-test", DisableStartupMessage: true})
|
||||
app := zip.New(zip.Config{AppName: "iam-registry-test", DisableStartupMessage: true})
|
||||
route(app.Group(""), db, func() (*keyring, error) { return kr, nil })
|
||||
return app, db, kr
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// In the cloud binary IAM is one of 59 subsystems on ONE *zip.App. It mounted its
|
||||
// Guard with app.Use, which is not "guard my routes" but "guard every route this
|
||||
// app will ever serve" — so `ai`, registered 97 positions later, had its /v1/models
|
||||
// gated by IAM, whose Guard then resolved the bearer against the EMBEDDED iam2.db
|
||||
// gated by IAM, whose Guard then resolved the bearer against the EMBEDDED iam.db
|
||||
// that has never seen a token minted by the external hanzo.id. Every valid request
|
||||
// 401'd with {"status":401,"error":"authentication required"} — this package's
|
||||
// Guard, wearing ai's URL.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package routes registers the IAM v2 HTTP surface on a zip App.
|
||||
// Package routes registers the IAM HTTP surface on a zip App.
|
||||
//
|
||||
// Authentication is STRUCTURAL, decided by which group a route is registered on,
|
||||
// never by a hand-maintained path list. Route binds the surface in two phases
|
||||
@@ -63,7 +63,7 @@ import (
|
||||
// mount its own guard, which is why they are named here rather than assumed.
|
||||
var guardedPrefixes = []string{"/v1/iam", "/login/oauth", "/mcp", "/.well-known/openapi.json"}
|
||||
|
||||
// Route registers the whole IAM v2 route surface on app, threading the entity
|
||||
// Route registers the whole IAM route surface on app, threading the entity
|
||||
// store db into every handler. This is the route table server.Route embeds — the
|
||||
// one Route(app, db) is the public entry; everything below is Route.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
@@ -119,7 +119,7 @@ func Route(app *zip.App, db orm.DB) {
|
||||
// That is coherent while IAM owns the whole app and false the moment it does
|
||||
// not: embedded in the cloud binary IAM mounts at position 9 and `ai` registers
|
||||
// /v1/models at 106, so IAM's Guard gated ai's routes 97 positions later — and
|
||||
// then resolved the bearer against the EMBEDDED iam2.db, which has never seen a
|
||||
// then resolved the bearer against the EMBEDDED iam.db, which has never seen a
|
||||
// token minted by the external hanzo.id, so it failed closed on every valid
|
||||
// request. The tell was the body: {"status":401,"error":"authentication
|
||||
// required"} is this file's Guard, not ai's OpenAI-shaped error.
|
||||
@@ -184,6 +184,6 @@ func health(c *zip.Ctx) error {
|
||||
return c.JSON(200, map[string]string{
|
||||
"status": "ok",
|
||||
"phase": "1",
|
||||
"binary": "iam2",
|
||||
"binary": "iam",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import "github.com/hanzoai/orm"
|
||||
// FederationState is ONE in-flight identity-federation transaction: the
|
||||
// server-side memory that spans the browser's round-trip to an external identity
|
||||
// provider (Google/GitHub, …) during an Authorization-Code federation, where
|
||||
// iam2 acts as the OIDC/OAuth2 Relying Party. It is what lets the IdP callback
|
||||
// RESUME the original iam2 authorize request, and it is the CSRF / replay guard
|
||||
// iam acts as the OIDC/OAuth2 Relying Party. It is what lets the IdP callback
|
||||
// RESUME the original iam authorize request, and it is the CSRF / replay guard
|
||||
// for that callback.
|
||||
//
|
||||
// Identity is the (Owner, Name) pair; Name IS the opaque 256-bit `state` value
|
||||
// iam2 sends to the IdP, so the callback resolves the transaction by the state
|
||||
// iam sends to the IdP, so the callback resolves the transaction by the state
|
||||
// the IdP reflects back. The row is single-use (Used) and expiring (ExpireIn),
|
||||
// and is bound to the initiating browser by BindHash — the SHA-256 of a
|
||||
// per-transaction anti-forgery cookie — so a state that is stolen or injected
|
||||
@@ -21,7 +21,7 @@ import "github.com/hanzoai/orm"
|
||||
// No IdP access/ID tokens are persisted here: only the material needed to VERIFY
|
||||
// the IdP response (the IdP-leg PKCE verifier and, for OIDC, the nonce checked
|
||||
// against the id_token) and to RESUME the app-leg (the original authorize
|
||||
// parameters, so the callback mints an iam2 authorization code identical to the
|
||||
// parameters, so the callback mints an iam authorization code identical to the
|
||||
// one a password login mints). The row is burned on consume.
|
||||
type FederationState struct {
|
||||
orm.Model[FederationState]
|
||||
@@ -37,8 +37,8 @@ type FederationState struct {
|
||||
// (e.g. "provider-google") — the connector whose callback this state authorizes.
|
||||
Provider string `json:"provider"`
|
||||
|
||||
// App-leg: the ORIGINAL iam2 authorize request, stashed so the callback can
|
||||
// resume it. The minted iam2 code is bound to CodeChallenge/RedirectUri/Nonce
|
||||
// App-leg: the ORIGINAL iam authorize request, stashed so the callback can
|
||||
// resume it. The minted iam code is bound to CodeChallenge/RedirectUri/Nonce
|
||||
// exactly as a password-login code is, so the relying party's existing PKCE
|
||||
// code→token exchange completes unchanged. AppState is echoed on the final
|
||||
// redirect back to the relying party.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// SCIM read-side org-scope + the cross-org existence oracle — the port of the
|
||||
// iam-v1 fix da0732a1 ("scope SCIM reads to caller org + collapse the 404/403
|
||||
// existence oracle") into the iam2 architecture.
|
||||
// existence oracle") into the iam architecture.
|
||||
//
|
||||
// THREAT (as it existed in iam-v1): Get/Delete/Replace/Patch resolved the SCIM id
|
||||
// GLOBALLY, then checked scope AFTER the load — so a tenant admin got a 404 for a
|
||||
@@ -10,7 +10,7 @@
|
||||
// 404-vs-403 split is a cross-org existence oracle: it confirms whether an
|
||||
// arbitrary user id / userName / email exists in a tenant the caller cannot see.
|
||||
//
|
||||
// iam2 closes it by CONSTRUCTION, one layer earlier than iam-v1 did: scopedTarget
|
||||
// iam closes it by CONSTRUCTION, one layer earlier than iam-v1 did: scopedTarget
|
||||
// (scim/users.go) re-pins the requested owner to the caller's OWN org through
|
||||
// authz.Scope for every non-super, on every verb, BEFORE the store is touched. A
|
||||
// non-super therefore never addresses a foreign row at all — the lookup key is
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package scim serves the SCIM 2.0 protocol (RFC 7644) over iam2's identity store
|
||||
// Package scim serves the SCIM 2.0 protocol (RFC 7644) over iam's identity store
|
||||
// — the STANDARD identity-provisioning surface that replaces the Casdoor entity
|
||||
// verbs (get-users/add-user/update-user/delete-user, …) per HIP-0111. There are
|
||||
// no "verbs": creating an identity is POST /Users, reading is GET, updating is
|
||||
@@ -40,7 +40,7 @@ func Route(app *zip.App, db orm.DB) {
|
||||
app.Get(base+"/ServiceProviderConfig", serviceProviderConfig)
|
||||
|
||||
// Users resource. The item path is {owner}/{name} because the SCIM id is
|
||||
// "owner/name" (iam2's natural key) and a client appends that opaque id
|
||||
// "owner/name" (iam's natural key) and a client appends that opaque id
|
||||
// verbatim — two segments, so no slash-in-id percent-encoding ambiguity.
|
||||
app.Get(base+"/Users", listUsers(db))
|
||||
app.Post(base+"/Users", createUser(db))
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// cert METADATA but no key material — the normal case, because a signing key
|
||||
// cannot live in the init_data.json ConfigMap (it is a secret). It generates a
|
||||
// keypair matching CryptoAlgorithm and PEM-encodes the private half, so the JWKS
|
||||
// endpoint publishes a key and iam2 can sign tokens — the same first-boot key
|
||||
// endpoint publishes a key and iam can sign tokens — the same first-boot key
|
||||
// provisioning the legacy Beego iam performs. It is gated to a reserved-org cert
|
||||
// (the only certs the JWKS publishes), leaves a cert that already carries key
|
||||
// material (PrivateKey or Certificate) or an SSL/unrecognized-alg cert untouched,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package seed bootstraps the iam2 store from an init_data.json file — the same
|
||||
// Package seed bootstraps the iam store from an init_data.json file — the same
|
||||
// file the Casdoor iam uses. This is the ported InitFromFile behavior: on boot,
|
||||
// upsert organizations, applications, providers, and certs so a fresh iam2
|
||||
// upsert organizations, applications, providers, and certs so a fresh iam
|
||||
// (embedded in cloud or standalone) comes up with the real app/provider/cert
|
||||
// config instead of an empty store.
|
||||
//
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// initData is the subset of the init_data.json shape iam2 seeds. Users and the
|
||||
// initData is the subset of the init_data.json shape iam seeds. Users and the
|
||||
// Casbin/LDAP/syncer artifacts are deliberately excluded — identity config only.
|
||||
type initData struct {
|
||||
Organizations []*schema.Organization `json:"organizations"`
|
||||
@@ -114,7 +114,7 @@ func Apply(ctx context.Context, db orm.DB, data *initData) (*Summary, error) {
|
||||
for _, c := range data.Certs {
|
||||
// A reserved-org signing cert arrives from init_data without key material
|
||||
// (secrets can't ride a ConfigMap); mint the keypair so the JWKS publishes a
|
||||
// key and iam2 can sign — persisted once by the new-only upsert below.
|
||||
// key and iam can sign — persisted once by the new-only upsert below.
|
||||
if err := ensureSigningKey(c); err != nil {
|
||||
return s, fmt.Errorf("seed: generate signing key for cert %s/%s: %w", c.Owner, c.Name, err)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestSubstituteEnv_UnsetBecomesEmpty(t *testing.T) {
|
||||
// TestSeed_GeneratesSigningKeyForKeylessReservedCert proves the fix for the empty
|
||||
// JWKS: a reserved-org signing cert arrives from init_data WITHOUT key material
|
||||
// (secrets can't ride a ConfigMap), and the seed mints a parseable keypair so the
|
||||
// JWKS endpoint publishes a key and iam2 can sign — while an SSL cert and a
|
||||
// JWKS endpoint publishes a key and iam can sign — while an SSL cert and a
|
||||
// tenant-owned cert are deliberately left keyless.
|
||||
func TestSeed_GeneratesSigningKeyForKeylessReservedCert(t *testing.T) {
|
||||
db := openDB(t)
|
||||
|
||||
@@ -45,7 +45,7 @@ var (
|
||||
// secret to provision and cookies survive restarts. Domain-separated so the key
|
||||
// can never collide with any other use of the cert.
|
||||
func SessionKey(certPrivateKeyPEM string) []byte {
|
||||
sum := sha256.Sum256([]byte("iam2.session.cookie.v1\x00" + certPrivateKeyPEM))
|
||||
sum := sha256.Sum256([]byte("iam.session.cookie.v1\x00" + certPrivateKeyPEM))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func memDB(t *testing.T) orm.DB {
|
||||
_ = schema.Kinds()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "iam2test.db"),
|
||||
Path: filepath.Join(dir, "iamtest.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package store is the IAM v2 object layer: thin, typed reads over hanzoai/orm
|
||||
// Package store is the IAM object layer: thin, typed reads over hanzoai/orm
|
||||
// against the Phase-1 entities. It replaces the v1 xorm ormer.Engine fluent
|
||||
// calls with orm.TypedQuery, so handlers depend on named operations
|
||||
// (GetApplicationByClientId, GetProvider, …) rather than a query builder.
|
||||
@@ -250,7 +250,7 @@ func IsReservedOrg(owner string) bool {
|
||||
// GetSigningCert resolves a TRUSTED signing certificate by name (the JWKS
|
||||
// `kid`), searching only the reserved platform owners in order. A cert owned by
|
||||
// any other org is never returned, so an attacker-created cert with a colliding
|
||||
// name can neither sign a token iam2 will verify nor be published in the JWKS.
|
||||
// name can neither sign a token iam will verify nor be published in the JWKS.
|
||||
// Returns (nil, nil) when no trusted cert carries the name.
|
||||
func GetSigningCert(ctx context.Context, db orm.DB, name string) (*schema.Cert, error) {
|
||||
if name == "" {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func openStoreTestDB(t *testing.T) orm.DB {
|
||||
t.Helper()
|
||||
db, err := Open("sqlite", filepath.Join(t.TempDir(), "iam2.db"))
|
||||
db, err := Open("sqlite", filepath.Join(t.TempDir(), "iam.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
@@ -42,8 +42,8 @@ func TestGetUserBySubject_ResolvesUUIDAndNaturalKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openStoreTestDB(t)
|
||||
const uuid = "e7d7fda0-4c53-4508-9d35-7ec892b7e5d7"
|
||||
seedRow(t, db, uuid, "hanzo", "z") // migrated: carries a UUID
|
||||
seedRow(t, db, "", "hanzo", "legacy") // pre-cutover: no Id
|
||||
seedRow(t, db, uuid, "hanzo", "z") // migrated: carries a UUID
|
||||
seedRow(t, db, "", "hanzo", "legacy") // pre-cutover: no Id
|
||||
|
||||
// A UUID subject resolves by Id.
|
||||
u, err := GetUserBySubject(ctx, db, uuid)
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
func openUsersTestDB(t *testing.T) (*API, func()) {
|
||||
t.Helper()
|
||||
db, err := store.Open("sqlite", filepath.Join(t.TempDir(), "iam2.db"))
|
||||
db, err := store.Open("sqlite", filepath.Join(t.TempDir(), "iam.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func openTestDB(t *testing.T) orm.DB {
|
||||
func newServer(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
app := zip.New(zip.Config{AppName: "iam2-wallet-test", DisableStartupMessage: true})
|
||||
app := zip.New(zip.Config{AppName: "iam-wallet-test", DisableStartupMessage: true})
|
||||
Route(app.Group(""), db)
|
||||
app.Use(authz.Guard(db))
|
||||
app.Authorize(authz.Authorize)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package wallet is native multi-chain wallet sign-in for IAM v2 (HIP-0111):
|
||||
// Package wallet is native multi-chain wallet sign-in for IAM (HIP-0111):
|
||||
// keyless CAIP-122 challenge/response over github.com/luxwallet/connect/go —
|
||||
// the SAME VerifyProof the TypeScript SDK runs, so Go and TS verify identically.
|
||||
//
|
||||
@@ -213,7 +213,7 @@ func check(db orm.DB) zip.Handler {
|
||||
// The multi-factor gate belongs HERE — after the wallet proves the
|
||||
// identity, before any session or code is minted. v1 runs checkMfaEnable
|
||||
// at exactly this point with an empty verificationType (wallet login has
|
||||
// no SMS/email pre-step). iam2 has no MFA gate yet; this is the ONE call
|
||||
// no SMS/email pre-step). iam has no MFA gate yet; this is the ONE call
|
||||
// site it binds into, so wallet login never becomes a silent MFA bypass.
|
||||
if factor(user, org) {
|
||||
return httpx.Err(c, "web3: multi-factor authentication is required")
|
||||
@@ -236,7 +236,7 @@ func check(db orm.DB) zip.Handler {
|
||||
}
|
||||
|
||||
// factor reports whether user must clear a second factor before a session is
|
||||
// minted. iam2 has no MFA gate yet, so it always reports "not required" — the
|
||||
// minted. iam has no MFA gate yet, so it always reports "not required" — the
|
||||
// call site above exists so the gate lands in one place rather than being
|
||||
// rediscovered, and so its absence is visible rather than silent.
|
||||
func factor(_ *schema.User, _ *schema.Organization) bool { return false }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Command iam2 is the Hanzo IAM v2 identity service: a clean-room,
|
||||
// Command iam is the Hanzo IAM identity service: a clean-room,
|
||||
// proprietary rewrite of the Casdoor-fork identity layer on the native
|
||||
// Hanzo stack — zip (HTTP) over hanzoai/orm. No Casdoor, no Beego, no xorm,
|
||||
// no base, no consensus engine.
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
// serve open the entity store and serve the IAM v2 API
|
||||
// serve open the entity store and serve the IAM API
|
||||
// compare read-only v1 -> v2 drift report (needs a `-tags migration` build)
|
||||
// version print the build version
|
||||
//
|
||||
@@ -52,15 +52,15 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "iam2",
|
||||
Short: "Hanzo IAM v2 — proprietary identity service (zip + orm, no Casdoor)",
|
||||
Use: "iam",
|
||||
Short: "Hanzo IAM — proprietary identity service (zip + orm, no Casdoor)",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
root.AddCommand(serveCmd(), compareCmd(), provisionCmd(), versionCmd())
|
||||
|
||||
if err := root.ExecuteContext(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "iam2: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "iam: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -69,14 +69,14 @@ func serveCmd() *cobra.Command {
|
||||
var storeBackend, dbPath, zapAddr, httpAddr, initData string
|
||||
cmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Open the entity store and serve the IAM v2 API",
|
||||
Short: "Open the entity store and serve the IAM API",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return serve(cmd.Context(), storeBackend, dbPath, zapAddr, httpAddr, initData)
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&storeBackend, "store", "sqlite", "storage backend: sqlite | sql | datastore")
|
||||
f.StringVar(&dbPath, "db", "data/iam2.db", "SQLite database path (store=sqlite)")
|
||||
f.StringVar(&dbPath, "db", "data/iam.db", "SQLite database path (store=sqlite)")
|
||||
f.StringVar(&zapAddr, "zap", ":9653", "ZAP primary listen address")
|
||||
f.StringVar(&httpAddr, "http", "http://:8080", "HTTP edge listen address")
|
||||
f.StringVar(&initData, "init-data", "", "path to init_data.json to seed on boot (new-only; ${VAR} from env)")
|
||||
@@ -106,7 +106,7 @@ func serve(ctx context.Context, storeBackend, dbPath, zapAddr, httpAddr, initDat
|
||||
if err != nil {
|
||||
return fmt.Errorf("serve: seed: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "iam2: seeded from %s — created orgs=%d apps=%d providers=%d certs=%d\n",
|
||||
fmt.Fprintf(os.Stderr, "iam: seeded from %s — created orgs=%d apps=%d providers=%d certs=%d\n",
|
||||
initData, sum.Created["organizations"], sum.Created["applications"],
|
||||
sum.Created["providers"], sum.Created["certs"])
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func serve(ctx context.Context, storeBackend, dbPath, zapAddr, httpAddr, initDat
|
||||
// endpoint. The authz Guard gates it like any other route (fail-closed), but
|
||||
// an identity service has no need to expose its admin CRUD as an agent tool
|
||||
// surface, so it is disabled outright — one fewer surface to defend.
|
||||
app := zip.New(zip.Config{AppName: "iam2", MCP: zip.MCPConfig{Disabled: true}})
|
||||
app := zip.New(zip.Config{AppName: "iam", MCP: zip.MCPConfig{Disabled: true}})
|
||||
routes.Route(app, db)
|
||||
app.OnShutdown(func(context.Context) error { return db.Close() })
|
||||
|
||||
@@ -153,7 +153,7 @@ func compareCmd() *cobra.Command {
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&store, "store", "sqlite", "v2 storage backend: sqlite | sql | datastore")
|
||||
f.StringVar(&dbPath, "db", "data/iam2.db", "v2 SQLite database path (store=sqlite)")
|
||||
f.StringVar(&dbPath, "db", "data/iam.db", "v2 SQLite database path (store=sqlite)")
|
||||
f.StringVar(&legacy, "legacy", "", "v1 Casdoor DSN (postgres:// or mysql://)")
|
||||
return cmd
|
||||
}
|
||||
@@ -226,9 +226,9 @@ func provisionCmd() *cobra.Command {
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the iam2 build version",
|
||||
Short: "Print the iam build version",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "iam2 %s\n", version)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "iam %s\n", version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package model exposes the iam2 core's identity value types to external feature
|
||||
// Package model exposes the iam core's identity value types to external feature
|
||||
// modules (hanzoiam/*) WITHOUT leaking internal/. They are ALIASES of the core
|
||||
// schema, so a module and the core share ONE type — no mapping, no drift.
|
||||
package model
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package store is the IN-PROCESS project store surface for a host binary that
|
||||
// EMBEDS iam2 (hanzoai/cloud) rather than talking to it over HTTP. It exposes the
|
||||
// EMBEDS iam (hanzoai/cloud) rather than talking to it over HTTP. It exposes the
|
||||
// ONE project CRUD path (internal/projects) as plain functions over an explicit
|
||||
// orm.DB — there is NO package-global engine in v2, so the db a host opened with
|
||||
// server.OpenSQLite (or bound from its own orm.DB) is passed on every call.
|
||||
|
||||
+19
-21
@@ -1,15 +1,13 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package server is the PUBLIC embedding surface of iam2: a host binary (cloud)
|
||||
// imports this and registers the full IAM v2 HTTP surface onto its own zip app,
|
||||
// over its own orm.DB. This is how iam2 goes live embedded in hanzoai/cloud
|
||||
// without a separate pod — the same multi-mode pattern cloud already uses for
|
||||
// the Casdoor iamserver, but zip-native and lean.
|
||||
// Package server is the PUBLIC embedding surface of iam: a host binary (cloud)
|
||||
// imports this and registers the full IAM HTTP surface onto its own zip app,
|
||||
// over its own orm.DB. This is how iam goes live embedded in hanzoai/cloud
|
||||
// without a separate pod.
|
||||
//
|
||||
// SHADOW-FIRST: the caller decides the route prefix. Registered under a shadow
|
||||
// prefix (e.g. /v2-iam) iam2 runs ALONGSIDE the live Casdoor /v1/iam/* with zero
|
||||
// impact; only once verified against real traffic does the host flip iam2 onto
|
||||
// the canonical /v1/iam/* paths. Never blind-replace live auth.
|
||||
// The caller decides the route prefix. It is normally the canonical /v1/iam/*;
|
||||
// a shadow prefix still works if a host wants to stand a second instance up
|
||||
// beside the live one before moving traffic.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -28,42 +26,42 @@ import (
|
||||
"github.com/hanzoai/iam/internal/seed"
|
||||
)
|
||||
|
||||
// Route registers the entire IAM v2 surface (OIDC discovery/JWKS, get-app-login,
|
||||
// Route registers the entire IAM surface (OIDC discovery/JWKS, get-app-login,
|
||||
// auth/methods, token, login, and the v2 entity CRUD) onto app, backed by db.
|
||||
// This is the one call a host binary makes to embed iam2.
|
||||
// This is the one call a host binary makes to embed iam.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
routes.Route(app, db)
|
||||
// Enterprise features (hanzoai/iam2/feature — SCIM/SAML/LDAP live in the
|
||||
// Enterprise features (hanzoai/iam/feature — SCIM/SAML/LDAP live in the
|
||||
// hanzoiam/* modules and Register themselves). No-op until a host registers
|
||||
// one; fail-fast if a registered module cannot register (a boot misconfiguration).
|
||||
if err := feature.RouteAll(app, featurestore.New(db)); err != nil {
|
||||
panic("iam2: enterprise feature registration failed: " + err.Error())
|
||||
panic("iam: enterprise feature registration failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// NewApp builds a STANDALONE iam2 zip.App over db — the whole IAM v2 surface
|
||||
// registered and Prepared as one self-contained app. A host that registers iam2 as a
|
||||
// NewApp builds a STANDALONE iam zip.App over db — the whole IAM surface
|
||||
// registered and Prepared as one self-contained app. A host that registers iam as a
|
||||
// wildcard sub-handler (app.All("/v1/iam/*", zip.AdaptNetHTTP(h))) rather than
|
||||
// co-mingling iam2's routes onto its own app uses this together with Handler; the
|
||||
// co-mingling iam's routes onto its own app uses this together with Handler; the
|
||||
// caller owns the returned app's Shutdown.
|
||||
func NewApp(db orm.DB) *zip.App {
|
||||
app := zip.New(zip.Config{AppName: "iam2", DisableStartupMessage: true})
|
||||
app := zip.New(zip.Config{AppName: "iam", DisableStartupMessage: true})
|
||||
Route(app, db)
|
||||
app.Prepare()
|
||||
return app
|
||||
}
|
||||
|
||||
// Handler adapts a standalone iam2 app (NewApp) to a net/http handler, so a host
|
||||
// router serves the whole IAM v2 surface behind ONE wildcard route. This is the
|
||||
// Handler adapts a standalone iam app (NewApp) to a net/http handler, so a host
|
||||
// router serves the whole IAM surface behind ONE wildcard route. This is the
|
||||
// drop-in shape hanzoai/cloud uses to swap the legacy Beego IAM catch-all for
|
||||
// iam2: registered at the /v1/iam/* (and root /.well-known/*) wildcards, the
|
||||
// iam: registered at the /v1/iam/* (and root /.well-known/*) wildcards, the
|
||||
// specific self-service routes layered in front still win by Fiber specificity,
|
||||
// so the swap is collision-free — the same topology the Beego catch-all had.
|
||||
func Handler(db orm.DB) http.Handler {
|
||||
return adaptor.FiberApp(NewApp(db).Fiber())
|
||||
}
|
||||
|
||||
// OpenSQLite opens an embedded SQLite store for iam2 at path (WAL). The host may
|
||||
// OpenSQLite opens an embedded SQLite store for iam at path (WAL). The host may
|
||||
// instead pass its own orm.DB (e.g. hanzoai/sql over ZAP) to Route.
|
||||
func OpenSQLite(path string) (orm.DB, error) {
|
||||
return orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
|
||||
Reference in New Issue
Block a user