mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
address IAM by its nouns, and present a credential when doing it
The five user verbs collapsed onto the user collection and the key half went to the key endpoint, so this client asks the same questions at the addresses that answer them: get-user?accessKey= -> GET /v1/iam/keys/principal (envelope) get-users?field= -> GET /v1/iam/users (the page itself) get-user?owner&email -> GET /v1/iam/users (the page itself) add-user -> POST /v1/iam/users (the record itself) Three things follow from the move rather than accompany it. The credential travels in the Authorization header. A query clientId is not a credential IAM reads, so every one of these reads would have arrived unauthenticated; the plugin installs the application credentials it already holds in Config where the client is built, and each read presents them. The collection narrows on the address and on nothing else. LookupByAttribute answers "email" and refuses any other attribute instead of sending a filter that would be ignored — an ignored filter answers with the org's first page, and every row on it reads as a match. EnsureUser reads before it writes. The create refuses a duplicate username and admits a duplicate address, so creating first would file a second row under one address and leave both unresolvable by it. A username lost to a racing provision still resolves by the address the two agree on. Where an address names two accounts the answer is a refusal, not the first row: that choice is how somebody acts under a colleague's identity. The team invite asks for two for the same reason and stays pending when it sees them. A resolved key carries the account the key endpoint names — owner/name, unique across tenants where a bare username is unique only inside one — and an unresolved one yields no user at all. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+202
-109
@@ -67,37 +67,38 @@ func writeErr(w http.ResponseWriter, msg string) {
|
||||
// LookupByAttribute
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// writePage writes the user collection's page: the records themselves, under no
|
||||
// envelope.
|
||||
func writePage(w http.ResponseWriter, users ...map[string]any) {
|
||||
if users == nil {
|
||||
users = []map[string]any{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"users": users, "total": len(users)})
|
||||
}
|
||||
|
||||
func TestLookupByAttribute_Hit(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
// Phone normalization probes multiple candidate formats: the canonical
|
||||
// `+16125551234`, the `+`-stripped `16125551234`, and the country-
|
||||
// code-stripped `6125551234`. Accept any of the three on the first
|
||||
// probe — the SDK's normalization order is its own internal choice
|
||||
// and the test should not be load-bearing on that order.
|
||||
validProbes := map[string]bool{
|
||||
"+16125551234": true,
|
||||
"16125551234": true,
|
||||
"6125551234": true,
|
||||
}
|
||||
f.setHandler("/v1/iam/get-users", func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("field"); got != "phone" {
|
||||
t.Errorf("field: got %q want phone", got)
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, secret, ok := r.BasicAuth()
|
||||
if !ok || id != "svc" || secret != "shh" {
|
||||
t.Errorf("credential did not arrive as Basic: id=%q ok=%v", id, ok)
|
||||
}
|
||||
if got := r.URL.Query().Get("value"); !validProbes[got] {
|
||||
t.Errorf("value: got %q want one of {+16125551234, 16125551234, 6125551234}", got)
|
||||
if got := r.URL.Query().Get("email"); got != "[email protected]" {
|
||||
t.Errorf("email: got %q want [email protected]", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("owner"); got != "hanzo" {
|
||||
t.Errorf("owner: got %q want hanzo", got)
|
||||
}
|
||||
writeOK(w, []map[string]any{
|
||||
{"id": "u-1", "name": "alice", "email": "[email protected]", "owner": "hanzo"},
|
||||
})
|
||||
if got := r.URL.Query().Get("limit"); got != "10" {
|
||||
t.Errorf("limit: got %q want 10", got)
|
||||
}
|
||||
writePage(w, map[string]any{"id": "u-1", "name": "alice", "email": "[email protected]", "owner": "hanzo"})
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
||||
|
||||
out, err := c.LookupByAttribute(context.Background(), "phone", "+16125551234", "", 10)
|
||||
out, err := c.LookupByAttribute(context.Background(), "email", "[email protected]", "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupByAttribute: %v", err)
|
||||
}
|
||||
@@ -108,8 +109,8 @@ func TestLookupByAttribute_Hit(t *testing.T) {
|
||||
|
||||
func TestLookupByAttribute_Miss(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
f.setHandler("/v1/iam/get-users", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOK(w, []map[string]any{})
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
writePage(w)
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
@@ -124,34 +125,29 @@ func TestLookupByAttribute_Miss(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupByAttribute_PhoneNormalization(t *testing.T) {
|
||||
// Verify the three-shape probe for phone: +16125551234 → 16125551234 → 6125551234.
|
||||
// The user actually exists under the raw US-national form ("6125551234"),
|
||||
// so the first two probes miss and the third hits.
|
||||
// An attribute the collection cannot narrow on is refused BEFORE the request. A
|
||||
// filter IAM does not offer is one it ignores, so sending it would answer with
|
||||
// the org's first page and every one of those rows would read as a match.
|
||||
func TestLookupByAttribute_RefusesUnfilterableAttribute(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
f.setHandler("/v1/iam/get-users", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("value") {
|
||||
case "6125551234":
|
||||
writeOK(w, []map[string]any{
|
||||
{"id": "u-7", "name": "bob", "email": "[email protected]", "owner": "hanzo"},
|
||||
})
|
||||
default:
|
||||
writeOK(w, []map[string]any{})
|
||||
}
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
writePage(w, map[string]any{"id": "u-9", "name": "somebody", "email": "[email protected]", "owner": "hanzo"})
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
||||
|
||||
out, err := c.LookupByAttribute(context.Background(), "phone", "+16125551234", "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupByAttribute: %v", err)
|
||||
for _, attr := range []string{"phone", "name", ""} {
|
||||
out, err := c.LookupByAttribute(context.Background(), attr, "+16125551234", "", 10)
|
||||
if err == nil {
|
||||
t.Fatalf("attr %q: want refusal, got %+v", attr, out)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatalf("attr %q: refusal must yield no users, got %+v", attr, out)
|
||||
}
|
||||
}
|
||||
if len(out) != 1 || out[0].ID != "u-7" {
|
||||
t.Fatalf("expected normalization to recover u-7, got %+v", out)
|
||||
}
|
||||
if got := f.callCount("/v1/iam/get-users"); got != 3 {
|
||||
t.Fatalf("expected 3 probes (raw, no-plus, no-US), got %d", got)
|
||||
if got := f.callCount("/v1/iam/users"); got != 0 {
|
||||
t.Fatalf("an unfilterable attribute reached IAM %d times", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,18 +159,22 @@ func TestLookupByAttribute_RequiresAdminCreds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupByAttribute_IAMErrorPropagates(t *testing.T) {
|
||||
func TestLookupByAttribute_IAMRefusalPropagates(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
f.setHandler("/v1/iam/get-users", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, "field not supported")
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"title":"forbidden: this credential is scoped to organization other"}`))
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
||||
|
||||
_, err := c.LookupByAttribute(context.Background(), "bogus", "x", "", 10)
|
||||
if err == nil || !strings.Contains(err.Error(), "field not supported") {
|
||||
t.Fatalf("want IAM-side error propagation, got %v", err)
|
||||
out, err := c.LookupByAttribute(context.Background(), "email", "x@y.com", "", 10)
|
||||
if err == nil || !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("want the refusal to propagate, got %+v / %v", out, err)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatalf("a refusal must yield no users, got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,27 +184,36 @@ func TestLookupByAttribute_IAMErrorPropagates(t *testing.T) {
|
||||
|
||||
func TestEnsureUser_Create(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
var addUserCalls, getUserCalls int64
|
||||
f.setHandler("/v1/iam/add-user", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&addUserCalls, 1)
|
||||
var reads, writes int64
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
atomic.AddInt64(&reads, 1)
|
||||
writePage(w)
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&writes, 1)
|
||||
if id, secret, ok := r.BasicAuth(); !ok || id != "svc" || secret != "shh" {
|
||||
t.Errorf("credential did not arrive as Basic: id=%q ok=%v", id, ok)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if payload["email"] != "[email protected]" {
|
||||
t.Errorf("email: got %v want [email protected]", payload["email"])
|
||||
var payload struct {
|
||||
User map[string]any `json:"user"`
|
||||
}
|
||||
if payload["owner"] != "hanzo" {
|
||||
t.Errorf("owner: got %v want hanzo", payload["owner"])
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode create body: %v", err)
|
||||
}
|
||||
writeOK(w, "Affected")
|
||||
})
|
||||
f.setHandler("/v1/iam/get-user", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&getUserCalls, 1)
|
||||
writeOK(w, map[string]any{
|
||||
"id": "new-id",
|
||||
"name": "new",
|
||||
"email": "[email protected]",
|
||||
"owner": "hanzo",
|
||||
// The profile rides under "user"; a flat body would decode to nothing.
|
||||
if payload.User == nil {
|
||||
t.Fatalf("create body carried no user object: %s", body)
|
||||
}
|
||||
if payload.User["email"] != "[email protected]" {
|
||||
t.Errorf("email: got %v want [email protected]", payload.User["email"])
|
||||
}
|
||||
if payload.User["owner"] != "hanzo" {
|
||||
t.Errorf("owner: got %v want hanzo", payload.User["owner"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "new-id", "name": "new", "email": "[email protected]", "owner": "hanzo",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,34 +232,28 @@ func TestEnsureUser_Create(t *testing.T) {
|
||||
if user.ID != "new-id" {
|
||||
t.Errorf("user.ID: got %q want new-id", user.ID)
|
||||
}
|
||||
if atomic.LoadInt64(&addUserCalls) != 1 {
|
||||
t.Errorf("add-user calls: got %d want 1", addUserCalls)
|
||||
}
|
||||
if atomic.LoadInt64(&getUserCalls) != 1 {
|
||||
t.Errorf("get-user calls: got %d want 1", getUserCalls)
|
||||
if atomic.LoadInt64(&reads) != 1 || atomic.LoadInt64(&writes) != 1 {
|
||||
t.Errorf("reads=%d writes=%d, want 1 and 1", reads, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUser_Idempotent_AlreadyExists(t *testing.T) {
|
||||
// IAM returns HTTP 200 with status:"error" + "X already exists"
|
||||
// when the user is already present. EnsureUser must treat this as
|
||||
// idempotent-replay and resolve the user via GET /v1/iam/get-user.
|
||||
// The address is read FIRST, so an account that is already there is returned
|
||||
// without a write. IAM's create refuses a duplicate username and admits a
|
||||
// duplicate address, so creating first would file a second row nothing can
|
||||
// resolve by address again.
|
||||
func TestEnsureUser_ExistingIsReturnedWithoutWriting(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
var addUserCalls int64
|
||||
f.setHandler("/v1/iam/add-user", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&addUserCalls, 1)
|
||||
writeErr(w, "user:Email already exists")
|
||||
})
|
||||
f.setHandler("/v1/iam/get-user", func(w http.ResponseWriter, r *http.Request) {
|
||||
var writes int64
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
atomic.AddInt64(&writes, 1)
|
||||
t.Errorf("EnsureUser wrote for an address already present")
|
||||
return
|
||||
}
|
||||
if got := r.URL.Query().Get("email"); got != "[email protected]" {
|
||||
t.Errorf("email: got %q want [email protected]", got)
|
||||
}
|
||||
writeOK(w, map[string]any{
|
||||
"id": "existing-id",
|
||||
"name": "dup",
|
||||
"email": "[email protected]",
|
||||
"owner": "hanzo",
|
||||
})
|
||||
writePage(w, map[string]any{"id": "existing-id", "name": "dup", "email": "[email protected]", "owner": "hanzo"})
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
@@ -258,28 +261,32 @@ func TestEnsureUser_Idempotent_AlreadyExists(t *testing.T) {
|
||||
|
||||
user, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{Email: "[email protected]", Name: "dup"})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUser on duplicate: %v", err)
|
||||
t.Fatalf("EnsureUser on existing: %v", err)
|
||||
}
|
||||
if user.ID != "existing-id" {
|
||||
t.Errorf("user.ID: got %q want existing-id", user.ID)
|
||||
}
|
||||
if atomic.LoadInt64(&writes) != 0 {
|
||||
t.Errorf("writes: got %d want 0", writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUser_Idempotent_HTTP409(t *testing.T) {
|
||||
// Some IAM versions / proxies may return HTTP 409 directly. EnsureUser
|
||||
// handles both signals.
|
||||
// The username was taken between the read and the write — the racing provision
|
||||
// that lost. Both agree on the address, so it resolves by that.
|
||||
func TestEnsureUser_RaceLosesOnNameAndResolvesByAddress(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
f.setHandler("/v1/iam/add-user", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"status":"error","msg":"already exists"}`))
|
||||
})
|
||||
f.setHandler("/v1/iam/get-user", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOK(w, map[string]any{
|
||||
"id": "existing-id",
|
||||
"name": "dup",
|
||||
"email": "[email protected]",
|
||||
"owner": "hanzo",
|
||||
})
|
||||
var reads int64
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"title":"user hanzo/dup already exists"}`))
|
||||
return
|
||||
}
|
||||
if atomic.AddInt64(&reads, 1) == 1 {
|
||||
writePage(w) // the read that lost the race saw nothing
|
||||
return
|
||||
}
|
||||
writePage(w, map[string]any{"id": "existing-id", "name": "dup", "email": "[email protected]", "owner": "hanzo"})
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
@@ -294,20 +301,106 @@ func TestEnsureUser_Idempotent_HTTP409(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUser_PropagatesNonExistsError(t *testing.T) {
|
||||
// Errors that are NOT "already exists" must propagate to the caller —
|
||||
// not get silently swallowed by the idempotent-replay path.
|
||||
// An address naming two accounts names none: refuse rather than pick one, and
|
||||
// write nothing.
|
||||
func TestEnsureUser_RefusesAmbiguousAddress(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
f.setHandler("/v1/iam/add-user", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, "organization not found")
|
||||
var writes int64
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
atomic.AddInt64(&writes, 1)
|
||||
return
|
||||
}
|
||||
writePage(w,
|
||||
map[string]any{"id": "u-1", "name": "a", "email": "[email protected]", "owner": "hanzo"},
|
||||
map[string]any{"id": "u-2", "name": "b", "email": "[email protected]", "owner": "hanzo"},
|
||||
)
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
||||
|
||||
_, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{Email: "x@y.com"})
|
||||
user, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{Email: "two@x.com"})
|
||||
if err == nil {
|
||||
t.Fatalf("want refusal on an ambiguous address, got %+v", user)
|
||||
}
|
||||
if user != nil {
|
||||
t.Fatalf("a refusal must yield no user, got %+v", user)
|
||||
}
|
||||
if atomic.LoadInt64(&writes) != 0 {
|
||||
t.Errorf("writes: got %d want 0", writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUser_PropagatesCreateRefusal(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
f.setHandler("/v1/iam/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writePage(w)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"title":"organization not found"}`))
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
||||
|
||||
user, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{Email: "[email protected]"})
|
||||
if err == nil || !strings.Contains(err.Error(), "organization not found") {
|
||||
t.Fatalf("want non-exists error to propagate, got %v", err)
|
||||
t.Fatalf("want the refusal to propagate, got %+v / %v", user, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ResolveAPIKey
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// A key resolves to the account the key endpoint names, and to nothing when it
|
||||
// does not resolve: a refusal must never become a default principal.
|
||||
func TestResolveAPIKey_ResolvesAndRefuses(t *testing.T) {
|
||||
f := newFakeIAM(t)
|
||||
f.setHandler("/v1/iam/keys/principal", func(w http.ResponseWriter, r *http.Request) {
|
||||
if id, secret, ok := r.BasicAuth(); !ok || id != "svc" || secret != "shh" {
|
||||
t.Errorf("credential did not arrive as Basic: id=%q ok=%v", id, ok)
|
||||
}
|
||||
if r.URL.Query().Get("accessKey") != "sk-good" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "error", "msg": "the entity does not exist", "code": "not-found",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeOK(w, map[string]any{
|
||||
"owner": "hanzo", "name": "svc", "email": "[email protected]", "isAdmin": false,
|
||||
})
|
||||
})
|
||||
|
||||
c := iam.NewClient(f.server.URL)
|
||||
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
||||
|
||||
user, err := c.ResolveAPIKey("sk-good")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAPIKey: %v", err)
|
||||
}
|
||||
if user.ID == "" {
|
||||
t.Fatalf("a resolved key must carry a subject, got %+v", user)
|
||||
}
|
||||
if user.ID != "hanzo/svc" || len(user.OrgIDs) != 1 || user.OrgIDs[0] != "hanzo" {
|
||||
t.Fatalf("resolved %+v", user)
|
||||
}
|
||||
|
||||
if bad, err := c.ResolveAPIKey("sk-bad"); err == nil {
|
||||
t.Fatalf("an unresolvable key must refuse, got %+v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// With no credential configured the resolver refuses rather than asking IAM
|
||||
// anonymously and reading whatever comes back.
|
||||
func TestResolveAPIKey_RequiresAdminCreds(t *testing.T) {
|
||||
c := iam.NewClient("http://unused.invalid")
|
||||
if user, err := c.ResolveAPIKey("sk-x"); err == nil {
|
||||
t.Fatalf("want refusal without credentials, got %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,9 +80,12 @@ func (p *plugin) handleInviteMember(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("member with this email already exists", nil)
|
||||
}
|
||||
|
||||
// Does the email already resolve to an IAM user in this org?
|
||||
// Does the email already resolve to an IAM user in this org? Ask for two, so
|
||||
// an address held by two accounts is VISIBLE: seating the invitee on whichever
|
||||
// row sorted first is how somebody joins a team under a colleague's identity.
|
||||
// An ambiguous address stays pending and goes through the invite instead.
|
||||
var iamUserID, iamName string
|
||||
if users, lookupErr := p.iam.LookupByAttribute(e.Request.Context(), "email", body.Email, id.Org, 1); lookupErr == nil && len(users) > 0 {
|
||||
if users, lookupErr := p.iam.LookupByAttribute(e.Request.Context(), "email", body.Email, id.Org, 2); lookupErr == nil && len(users) == 1 {
|
||||
iamUserID = users[0].ID
|
||||
iamName = users[0].Name
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ func fakeIAM(t *testing.T) *httptest.Server {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "user-123", "email": "[email protected]", "name": "Z", "orgIds": []string{"lux"},
|
||||
})
|
||||
case "/v1/iam/get-users": // LookupByAttribute (team invite)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "data": []any{}})
|
||||
case "/v1/iam/users": // LookupByAttribute (team invite)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"users": []any{}, "total": 0})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
+165
-229
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -309,8 +310,8 @@ func IsWidgetKey(token string) bool {
|
||||
}
|
||||
|
||||
// ResolveAPIKey resolves an IAM API key (hk-/pk-/sk-) to user + org context.
|
||||
// Uses IAM's GET /v1/iam/get-user?accessKey= endpoint. Results are cached for
|
||||
// tokenCacheTTL; concurrent resolves of the same key are coalesced via
|
||||
// Uses IAM's GET /v1/iam/keys/principal?accessKey= endpoint. Results are cached
|
||||
// for tokenCacheTTL; concurrent resolves of the same key are coalesced via
|
||||
// singleflight.
|
||||
func (c *IAMClient) ResolveAPIKey(accessKey string) (*IAMUser, error) {
|
||||
if user, ok := c.keys.Get(accessKey); ok {
|
||||
@@ -336,12 +337,18 @@ func (c *IAMClient) ResolveAPIKey(accessKey string) (*IAMUser, error) {
|
||||
}
|
||||
|
||||
func (c *IAMClient) fetchUserByKey(accessKey string) (*IAMUser, error) {
|
||||
u := c.baseURL + "/v1/iam/get-user?accessKey=" + url.QueryEscape(accessKey)
|
||||
creds := c.adminCreds()
|
||||
if creds.ClientID == "" || creds.ClientSecret == "" {
|
||||
return nil, fmt.Errorf("iam: ResolveAPIKey: admin credentials not configured (call SetAdminCreds)")
|
||||
}
|
||||
u := c.baseURL + "/v1/iam/keys/principal?accessKey=" + url.QueryEscape(accessKey)
|
||||
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: create key request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(creds.ClientID, creds.ClientSecret)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -351,43 +358,51 @@ func (c *IAMClient) fetchUserByKey(accessKey string) (*IAMUser, error) {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("iam: get-user returned %d: %s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("iam: keys/principal returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
var envelope struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data *struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("iam: decode key user: %w", err)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
return nil, fmt.Errorf("iam: decode key principal: %w", err)
|
||||
}
|
||||
|
||||
if result.Data.Name == "" {
|
||||
if envelope.Status != "ok" || envelope.Data == nil {
|
||||
return nil, fmt.Errorf("iam: key did not resolve: %s", envelope.Msg)
|
||||
}
|
||||
if envelope.Data.Owner == "" || envelope.Data.Name == "" {
|
||||
return nil, fmt.Errorf("iam: key resolved to empty user")
|
||||
}
|
||||
|
||||
// The key endpoint answers with the account, never its opaque id: what it
|
||||
// discloses is deliberately the narrowest projection a resolver needs. So the
|
||||
// subject is the account's own canonical spelling, owner/name, which is unique
|
||||
// across tenants where a bare username is unique only inside one.
|
||||
return &IAMUser{
|
||||
ID: result.Data.ID,
|
||||
Name: result.Data.Name,
|
||||
Email: result.Data.Email,
|
||||
OrgIDs: []string{result.Data.Owner},
|
||||
ID: envelope.Data.Owner + "/" + envelope.Data.Name,
|
||||
Name: envelope.Data.Name,
|
||||
Email: envelope.Data.Email,
|
||||
OrgIDs: []string{envelope.Data.Owner},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Server-to-Server User Operations ────────────────────────────────────
|
||||
//
|
||||
// These methods authenticate using clientId + clientSecret (IAM application
|
||||
// credentials) and bypass session auth. They're for service-to-service flows
|
||||
// where a downstream service needs to look up or provision IAM users
|
||||
// (onboarding, KYC reconciliation, deduplication).
|
||||
// These methods present the calling service's OWN IAM application credentials
|
||||
// as client_secret_basic, and carry no session. They are for the flows where a
|
||||
// service looks up or provisions IAM users (onboarding, KYC reconciliation,
|
||||
// deduplication).
|
||||
//
|
||||
// The clientId/clientSecret used here are the calling *service*'s own IAM
|
||||
// application credentials. They authorize reads against the configured org.
|
||||
// They do NOT grant superuser scope.
|
||||
// What those credentials reach is the application's capability allowlist and the
|
||||
// tenant it serves — never platform scope, and never another tenant's rows. IAM
|
||||
// refuses an owner the credential is not scoped to rather than answering with
|
||||
// its own, so a lookup either reads the org it named or fails.
|
||||
|
||||
// AdminCreds holds the service's IAM application credentials. Pass these to
|
||||
// the client via SetAdminCreds before invoking server-to-server methods.
|
||||
@@ -415,41 +430,6 @@ type EnsureUserSpec struct {
|
||||
Type string // IAM user type, e.g. "normal-user"
|
||||
}
|
||||
|
||||
// normalizePhoneCandidates returns the set of phone shapes IAM might have
|
||||
// stored. IAM inconsistently persists phones — some rows include the
|
||||
// leading "+", some are raw digits, US numbers sometimes omit the +1 country
|
||||
// code. We probe all three shapes to catch either persistence path.
|
||||
func normalizePhoneCandidates(phone string) []string {
|
||||
if phone == "" {
|
||||
return nil
|
||||
}
|
||||
out := []string{phone}
|
||||
if strings.HasPrefix(phone, "+") {
|
||||
stripped := strings.TrimPrefix(phone, "+")
|
||||
if stripped != "" {
|
||||
out = append(out, stripped)
|
||||
}
|
||||
// US: drop +1 country code to match a raw 10-digit national number.
|
||||
if strings.HasPrefix(stripped, "1") {
|
||||
noUS := strings.TrimPrefix(stripped, "1")
|
||||
if noUS != "" {
|
||||
out = append(out, noUS)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicate while preserving order.
|
||||
seen := make(map[string]struct{}, len(out))
|
||||
deduped := out[:0]
|
||||
for _, v := range out {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
deduped = append(deduped, v)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
// adminCreds returns a snapshot of the configured admin credentials.
|
||||
func (c *IAMClient) adminCreds() AdminCreds {
|
||||
c.mu.RLock()
|
||||
@@ -458,19 +438,19 @@ func (c *IAMClient) adminCreds() AdminCreds {
|
||||
}
|
||||
|
||||
// LookupByAttribute performs a server-to-server lookup of users matching
|
||||
// attr=value within org. attr is an IAM user field name ("phone", "email",
|
||||
// "name", etc.). org defaults to the client's admin Owner if empty.
|
||||
// attr=value within org. org defaults to the client's admin Owner if empty.
|
||||
// maxResults caps the page size; values <= 0 default to 10.
|
||||
//
|
||||
// For attr=="phone", LookupByAttribute probes multiple phone normalizations
|
||||
// (raw, with leading "+", US +1 stripped) since IAM stores phones in
|
||||
// inconsistent shapes depending on the signup path.
|
||||
// IAM's user collection narrows on ONE attribute, the email address, so "email"
|
||||
// is the only attr this answers. Any other name is refused rather than sent: a
|
||||
// filter IAM does not offer is a filter it ignores, the reply is then the org's
|
||||
// first page, and a caller reading that as a match binds the wrong person.
|
||||
//
|
||||
// Returns ([], nil) when no user matches — never an error for empty results.
|
||||
// Errors are returned only for transport / decoding / IAM-side error responses.
|
||||
func (c *IAMClient) LookupByAttribute(ctx context.Context, attr, value, org string, maxResults int) ([]IAMUser, error) {
|
||||
if attr == "" {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute: attr is required")
|
||||
if attr != "email" {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute: no %q filter; the user collection narrows on email", attr)
|
||||
}
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute: value is required")
|
||||
@@ -489,88 +469,79 @@ func (c *IAMClient) LookupByAttribute(ctx context.Context, attr, value, org stri
|
||||
maxResults = 10
|
||||
}
|
||||
|
||||
// Build candidate values. Phone gets multiple normalizations; everything
|
||||
// else uses the value verbatim.
|
||||
candidates := []string{value}
|
||||
if attr == "phone" {
|
||||
candidates = normalizePhoneCandidates(value)
|
||||
users, err := c.listUsers(ctx, org, value, maxResults)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// listUsers reads the page of users in owner carrying email. It is the ONE place
|
||||
// this client addresses the user collection, so a lookup and a provision cannot
|
||||
// come to disagree about where that collection is or what it answers with.
|
||||
//
|
||||
// A page is the record list itself — no envelope — and an owner that holds no
|
||||
// match is an empty list, not an error.
|
||||
func (c *IAMClient) listUsers(ctx context.Context, owner, email string, limit int) ([]IAMUser, error) {
|
||||
creds := c.adminCreds()
|
||||
q := url.Values{}
|
||||
q.Set("owner", owner)
|
||||
q.Set("email", email)
|
||||
if limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
|
||||
var matches []IAMUser
|
||||
seenIDs := make(map[string]struct{})
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/v1/iam/users?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(creds.ClientID, creds.ClientSecret)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request: %w", err)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("users returned %d: %s", resp.StatusCode, truncate(string(body), 256))
|
||||
}
|
||||
|
||||
var page struct {
|
||||
Users []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"users"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
out := make([]IAMUser, 0, len(page.Users))
|
||||
for _, u := range page.Users {
|
||||
if u.ID == "" {
|
||||
continue
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("owner", org)
|
||||
q.Set("clientId", creds.ClientID)
|
||||
q.Set("clientSecret", creds.ClientSecret)
|
||||
q.Set("pageSize", fmt.Sprintf("%d", maxResults))
|
||||
q.Set("p", "1")
|
||||
q.Set("field", attr)
|
||||
q.Set("value", candidate)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/v1/iam/get-users?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute build request: %w", err)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute request: %w", err)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute returned %d: %s", resp.StatusCode, truncate(string(body), 256))
|
||||
}
|
||||
var envelope struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute decode: %w", err)
|
||||
}
|
||||
if envelope.Status == "error" {
|
||||
return nil, fmt.Errorf("iam: LookupByAttribute: %s", envelope.Msg)
|
||||
}
|
||||
for _, u := range envelope.Data {
|
||||
if u.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenIDs[u.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenIDs[u.ID] = struct{}{}
|
||||
matches = append(matches, IAMUser{
|
||||
ID: u.ID,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
OrgIDs: []string{u.Owner},
|
||||
})
|
||||
if len(matches) >= maxResults {
|
||||
return matches, nil
|
||||
}
|
||||
}
|
||||
out = append(out, IAMUser{ID: u.ID, Name: u.Name, Email: u.Email, OrgIDs: []string{u.Owner}})
|
||||
}
|
||||
return matches, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnsureUser idempotently provisions an IAM user matching spec. If the user
|
||||
// already exists (matched by email within spec.Owner), the existing user is
|
||||
// returned without modification. Otherwise the user is created via
|
||||
// POST /v1/iam/add-user and the new user is fetched and returned.
|
||||
// POST /v1/iam/users and the created record is returned.
|
||||
//
|
||||
// EnsureUser treats both HTTP 409 and IAM's status:"error" + "already exists"
|
||||
// envelope as the idempotent-replay path — IAM responds with HTTP 200 in
|
||||
// either case depending on version, and both shapes mean "this user is
|
||||
// already there, fetch it".
|
||||
// The ADDRESS decides, so the read comes first. IAM's create refuses a duplicate
|
||||
// username and admits a duplicate address, and a second row under one address is
|
||||
// a row no lookup by address will resolve again — it makes both unreachable.
|
||||
// Creating first and treating the refusal as "already there" would be that write.
|
||||
//
|
||||
// spec.Email is required (used as the dedup key). spec.Owner defaults to the
|
||||
// client's admin Owner if empty.
|
||||
@@ -597,33 +568,41 @@ func (c *IAMClient) EnsureUser(ctx context.Context, spec EnsureUserSpec) (*IAMUs
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"owner": owner,
|
||||
"name": name,
|
||||
"email": spec.Email,
|
||||
"displayName": spec.DisplayName,
|
||||
"phone": spec.Phone,
|
||||
"type": spec.Type,
|
||||
"organization": owner,
|
||||
existing, err := c.listUsers(ctx, owner, spec.Email, 2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: EnsureUser: %w", err)
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if len(existing) > 1 {
|
||||
return nil, fmt.Errorf("iam: EnsureUser: email=%s names %d users in owner=%s", spec.Email, len(existing), owner)
|
||||
}
|
||||
if len(existing) == 1 {
|
||||
return &existing[0], nil
|
||||
}
|
||||
|
||||
// The profile rides under "user": the create body carries the record beside
|
||||
// the write-only fields that never land on it, so a flat body reads as a user
|
||||
// with nothing in it.
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"user": map[string]any{
|
||||
"owner": owner,
|
||||
"name": name,
|
||||
"email": spec.Email,
|
||||
"displayName": spec.DisplayName,
|
||||
"phone": spec.Phone,
|
||||
"type": spec.Type,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: EnsureUser marshal: %w", err)
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("clientId", creds.ClientID)
|
||||
q.Set("clientSecret", creds.ClientSecret)
|
||||
// IAM's add-user uses `id` (owner/name) for routing; include both for
|
||||
// safety against handlers that read either shape.
|
||||
q.Set("id", owner+"/"+name)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST",
|
||||
c.baseURL+"/v1/iam/add-user?"+q.Encode(), bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/iam/users", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: EnsureUser build request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(creds.ClientID, creds.ClientSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -632,98 +611,55 @@ func (c *IAMClient) EnsureUser(ctx context.Context, spec EnsureUserSpec) (*IAMUs
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
|
||||
// HTTP 409 → already exists, fetch and return.
|
||||
// The username was taken between the read above and this write — the racing
|
||||
// provision that lost. Both agree on the address, so resolve by it.
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
return c.fetchUserByEmail(ctx, owner, spec.Email)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, fmt.Errorf("iam: EnsureUser returned %d: %s",
|
||||
resp.StatusCode, truncate(string(respBody), 256))
|
||||
}
|
||||
|
||||
// HTTP 200 — could be success (status:ok) or IAM-style error envelope.
|
||||
var envelope struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &envelope); err != nil {
|
||||
if err := json.Unmarshal(respBody, &created); err != nil {
|
||||
return nil, fmt.Errorf("iam: EnsureUser decode: %w", err)
|
||||
}
|
||||
if envelope.Status == "error" {
|
||||
// IAM's idempotent-replay signal: "X already exists". Could be
|
||||
// username, email, or phone — any of them means the user is in IAM
|
||||
// already and we should resolve it by email.
|
||||
if isAlreadyExistsMsg(envelope.Msg) {
|
||||
return c.fetchUserByEmail(ctx, owner, spec.Email)
|
||||
}
|
||||
return nil, fmt.Errorf("iam: EnsureUser: %s", envelope.Msg)
|
||||
}
|
||||
// Success — fetch the freshly-created user to get its IAM-assigned ID.
|
||||
return c.fetchUserByEmail(ctx, owner, spec.Email)
|
||||
}
|
||||
|
||||
// fetchUserByEmail does a server-to-server lookup of a user by email within
|
||||
// the given org. Used by EnsureUser to resolve both new and already-existing
|
||||
// users to a canonical *IAMUser.
|
||||
func (c *IAMClient) fetchUserByEmail(ctx context.Context, owner, email string) (*IAMUser, error) {
|
||||
creds := c.adminCreds()
|
||||
q := url.Values{}
|
||||
q.Set("owner", owner)
|
||||
q.Set("email", email)
|
||||
q.Set("clientId", creds.ClientID)
|
||||
q.Set("clientSecret", creds.ClientSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET",
|
||||
c.baseURL+"/v1/iam/get-user?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail build request: %w", err)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail request: %w", err)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail returned %d: %s",
|
||||
resp.StatusCode, truncate(string(body), 256))
|
||||
}
|
||||
var envelope struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail decode: %w", err)
|
||||
}
|
||||
if envelope.Status == "error" {
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail: %s", envelope.Msg)
|
||||
}
|
||||
if envelope.Data.ID == "" {
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail: no user found for email=%s in owner=%s", email, owner)
|
||||
if created.ID == "" {
|
||||
return nil, fmt.Errorf("iam: EnsureUser: created user carries no id")
|
||||
}
|
||||
return &IAMUser{
|
||||
ID: envelope.Data.ID,
|
||||
Name: envelope.Data.Name,
|
||||
Email: envelope.Data.Email,
|
||||
OrgIDs: []string{envelope.Data.Owner},
|
||||
ID: created.ID,
|
||||
Name: created.Name,
|
||||
Email: created.Email,
|
||||
OrgIDs: []string{created.Owner},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isAlreadyExistsMsg recognizes IAM's various "X already exists" error messages
|
||||
// emitted by add-user when the username/email/phone collides.
|
||||
func isAlreadyExistsMsg(msg string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
// fetchUserByEmail resolves the ONE user carrying email within owner. Used by
|
||||
// EnsureUser to resolve a user a racing provision created first.
|
||||
//
|
||||
// An address naming two accounts names none: two rows refuse rather than hand
|
||||
// back the first, because choosing between them is how somebody ends up acting
|
||||
// under a colleague's identity.
|
||||
func (c *IAMClient) fetchUserByEmail(ctx context.Context, owner, email string) (*IAMUser, error) {
|
||||
users, err := c.listUsers(ctx, owner, email, 2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail: %w", err)
|
||||
}
|
||||
switch len(users) {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail: no user found for email=%s in owner=%s", email, owner)
|
||||
case 1:
|
||||
return &users[0], nil
|
||||
default:
|
||||
return nil, fmt.Errorf("iam: fetchUserByEmail: email=%s names %d users in owner=%s", email, len(users), owner)
|
||||
}
|
||||
low := strings.ToLower(msg)
|
||||
return strings.Contains(low, "already exists")
|
||||
}
|
||||
|
||||
// truncate clips s to at most n runes, appending "…" if truncated. Used for
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// every address it listens on, and the address SCHEME names the transport: a
|
||||
// bare host:port is ZAP, "http://" is HTTP. IAM's deployment listens on both —
|
||||
// `iam serve --zap :9653 --http http://:8000` — so /v1/iam/oauth/userinfo,
|
||||
// /v1/iam/get-user and the JWKS answer identically on either wire.
|
||||
// /v1/iam/users and the JWKS answer identically on either wire.
|
||||
//
|
||||
// So this is not a second client, a second protocol, or a second set of paths.
|
||||
// It is the same request, addressed the same way, carried over the transport the
|
||||
|
||||
@@ -28,13 +28,14 @@ func serveIAM(t *testing.T) (addr string, seen *string) {
|
||||
"orgIds": []string{"hanzo"},
|
||||
})
|
||||
})
|
||||
app.Get("/v1/iam/get-user", func(c *zip.Ctx) error {
|
||||
app.Get("/v1/iam/keys/principal", func(c *zip.Ctx) error {
|
||||
if c.Query("accessKey") != "sk-probe" {
|
||||
return c.JSON(404, map[string]string{"status": "error"})
|
||||
return c.JSON(400, map[string]string{"status": "error", "msg": "the entity does not exist"})
|
||||
}
|
||||
return c.JSON(200, map[string]any{
|
||||
"data": map[string]string{
|
||||
"id": "u-2", "name": "svc", "email": "[email protected]", "owner": "hanzo",
|
||||
"status": "ok",
|
||||
"data": map[string]any{
|
||||
"name": "svc", "email": "[email protected]", "owner": "hanzo", "isAdmin": false,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -80,11 +81,12 @@ func TestIAMClientOverZAP(t *testing.T) {
|
||||
t.Fatalf("authorization did not cross the wire: %q", *seen)
|
||||
}
|
||||
|
||||
c.SetAdminCreds(AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
||||
key, err := c.ResolveAPIKey("sk-probe")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAPIKey over zap: %v", err)
|
||||
}
|
||||
if key.ID != "u-2" || len(key.OrgIDs) != 1 || key.OrgIDs[0] != "hanzo" {
|
||||
if key.ID != "hanzo/svc" || len(key.OrgIDs) != 1 || key.OrgIDs[0] != "hanzo" {
|
||||
t.Fatalf("decoded %+v", key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,11 +184,12 @@ func TestAKeyActsInItsOwnOrg(t *testing.T) {
|
||||
// The half of IAM a key resolves against, beside the JWKS one.
|
||||
keys := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"id":"u_key","name":"keyuser","email":"[email protected]","owner":"alpha"}}`))
|
||||
_, _ = w.Write([]byte(`{"status":"ok","data":{"name":"keyuser","email":"[email protected]","owner":"alpha"}}`))
|
||||
}))
|
||||
defer keys.Close()
|
||||
|
||||
if err := Register(app, Config{IAMEndpoint: keys.URL, KMSEndpoint: "127.0.0.1:1"}); err != nil {
|
||||
if err := Register(app, Config{IAMEndpoint: keys.URL, KMSEndpoint: "127.0.0.1:1",
|
||||
IAMClientID: "svc", IAMClientSecret: "shh"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.Store().Set("jwksURL", iam.url+"/v1/iam/.well-known/jwks")
|
||||
@@ -237,11 +238,12 @@ func keyed(t *testing.T, owner string, extra ...func(*core.ServeEvent)) (core.Ap
|
||||
iam := newIssuer(t)
|
||||
keys := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"id":"u_key","name":"keyuser","email":"[email protected]","owner":"` + owner + `"}}`))
|
||||
_, _ = w.Write([]byte(`{"status":"ok","data":{"name":"keyuser","email":"[email protected]","owner":"` + owner + `"}}`))
|
||||
}))
|
||||
t.Cleanup(keys.Close)
|
||||
|
||||
if err := Register(app, Config{IAMEndpoint: keys.URL, KMSEndpoint: "127.0.0.1:1"}); err != nil {
|
||||
if err := Register(app, Config{IAMEndpoint: keys.URL, KMSEndpoint: "127.0.0.1:1",
|
||||
IAMClientID: "svc", IAMClientSecret: "shh"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.Store().Set("jwksURL", iam.url+"/v1/iam/.well-known/jwks")
|
||||
@@ -509,7 +511,7 @@ func TestTheAuditLogIsTheOperatorsAndCarriesNoSecret(t *testing.T) {
|
||||
if auth, _ := row.Data["auth"].(string); auth != "key" {
|
||||
t.Errorf("a keyed request was attributed to %q", auth)
|
||||
}
|
||||
if id, _ := row.Data["authId"].(string); id != "u_key" {
|
||||
if id, _ := row.Data["authId"].(string); id != "alpha/keyuser" {
|
||||
t.Errorf("a keyed request named the subject %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -159,10 +159,21 @@ func Register(app core.App, config Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// The service's OWN IAM application credentials, installed where the client is
|
||||
// built. Every server-to-server read presents them — resolving a key to its
|
||||
// holder, reading a user by address — so a caller cannot reach IAM without one
|
||||
// and read whatever an anonymous request happens to answer with.
|
||||
iamClient := NewIAMClient(readAddress(config))
|
||||
iamClient.SetAdminCreds(AdminCreds{
|
||||
ClientID: config.IAMClientID,
|
||||
ClientSecret: config.IAMClientSecret,
|
||||
Owner: config.IAMOrg,
|
||||
})
|
||||
|
||||
p := &plugin{
|
||||
app: app,
|
||||
config: config,
|
||||
iam: NewIAMClient(readAddress(config)),
|
||||
iam: iamClient,
|
||||
compliance: NewComplianceClient(config.ComplianceEndpoint, config.ComplianceAPIKey),
|
||||
org: &OrgService{app: app, kms: kmsClient, config: config},
|
||||
orgDB: NewOrgDB(app, config.principalKey()),
|
||||
|
||||
Reference in New Issue
Block a user