diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md index b49bbfa..eb1060b 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -75,6 +75,27 @@ Company-first sponsor path is also supported: - If `sponsor_org_root_id` is provided and the `payer_wallet` is a stored `org_root_owner` principal for that org root with active entitlement status, quote issuance is allowed without `payer_proof`. +## Identity Assurance Model + +Membership activation and identity assurance are stored as separate facts: + +1. `membership_status` +2. `identity_assurance_level` + +Assurance levels: + +1. `none` +2. `crypto_direct_unattested` +3. `sponsored_unattested` +4. `onramp_attested` + +`onramp_attested` can be set during membership confirm only on self-paid quotes and requires `identity_attested_by`. + +Policy gates: + +1. Store checkout requires active membership. +2. Workspace admin install/support actions require `onramp_attested` assurance. + ## Key Environment Variables ### Core diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index 0e7a5ee..243068c 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -121,17 +121,18 @@ func (a *app) handleWalletIntent(w http.ResponseWriter, r *http.Request) { expiresAt := issuedAt.Add(a.cfg.IntentTTL) record := designationRecord{ - Code: code, - DisplayToken: displayToken, - IntentID: intentID, - Nonce: nonce, - Origin: strings.TrimSpace(req.Origin), - Locale: strings.TrimSpace(req.Locale), - Address: address, - ChainID: req.ChainID, - IssuedAt: issuedAt, - ExpiresAt: expiresAt, - MembershipStatus: "none", + Code: code, + DisplayToken: displayToken, + IntentID: intentID, + Nonce: nonce, + Origin: strings.TrimSpace(req.Origin), + Locale: strings.TrimSpace(req.Locale), + Address: address, + ChainID: req.ChainID, + IssuedAt: issuedAt, + ExpiresAt: expiresAt, + MembershipStatus: "none", + IdentityAssurance: assuranceNone, } if record.Origin == "" { record.Origin = "https://edut.ai" @@ -438,9 +439,18 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) { return } now := time.Now().UTC() + identityAssurance, identityAttestedBy, identityAttestationID, identityAttestedAt, assuranceErr := resolveMembershipAssurance(quote, req, now) + if assuranceErr != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_identity_assurance", assuranceErr.Error()) + return + } rec.MembershipStatus = "active" rec.MembershipTxHash = strings.ToLower(req.TxHash) rec.ActivatedAt = &now + rec.IdentityAssurance = identityAssurance + rec.IdentityAttestedBy = identityAttestedBy + rec.IdentityAttestationID = identityAttestationID + rec.IdentityAttestedAt = identityAttestedAt if err := a.store.putDesignation(r.Context(), rec); err != nil { writeError(w, http.StatusInternalServerError, "failed to persist membership activation") return @@ -454,11 +464,14 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, membershipConfirmResponse{ - Status: "membership_active", - DesignationCode: rec.Code, - DisplayToken: rec.DisplayToken, - TxHash: strings.ToLower(req.TxHash), - ActivatedAt: now.Format(time.RFC3339Nano), + Status: "membership_active", + DesignationCode: rec.Code, + DisplayToken: rec.DisplayToken, + TxHash: strings.ToLower(req.TxHash), + ActivatedAt: now.Format(time.RFC3339Nano), + IdentityAssurance: rec.IdentityAssurance, + IdentityAttestedBy: rec.IdentityAttestedBy, + IdentityAttestationID: rec.IdentityAttestationID, }) } @@ -503,9 +516,12 @@ func (a *app) handleMembershipStatus(w http.ResponseWriter, r *http.Request) { status = "none" } writeJSON(w, http.StatusOK, membershipStatusResponse{ - Status: status, - Wallet: rec.Address, - DesignationCode: rec.Code, + Status: status, + Wallet: rec.Address, + DesignationCode: rec.Code, + IdentityAssurance: normalizeAssuranceLevel(rec.IdentityAssurance), + IdentityAttestedBy: strings.TrimSpace(rec.IdentityAttestedBy), + IdentityAttestationID: strings.TrimSpace(rec.IdentityAttestationID), }) } @@ -542,6 +558,10 @@ func (a *app) handleGovernanceInstallToken(w http.ResponseWriter, r *http.Reques writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active") return } + if !isOnrampAttested(rec.IdentityAssurance) { + writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "workspace admin operations require onramp_attested identity assurance") + return + } principal, err := a.resolveOrCreatePrincipal(r.Context(), wallet, req.OrgRootID, req.PrincipalID, req.PrincipalRole) if err != nil { @@ -734,16 +754,19 @@ func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Reque } membershipStatus := "none" + identityAssurance := assuranceNone if rec, err := a.store.getDesignationByAddress(r.Context(), wallet); err == nil { membershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus)) if membershipStatus == "" { membershipStatus = "none" } + identityAssurance = normalizeAssuranceLevel(rec.IdentityAssurance) } resp := governanceInstallStatusResponse{ Wallet: wallet, MembershipStatus: membershipStatus, + IdentityAssurance: identityAssurance, EntitlementStatus: "unknown", AccessClass: "unknown", AvailabilityState: "unknown", @@ -1171,6 +1194,10 @@ func (a *app) handleMemberChannelSupportTicket(w http.ResponseWriter, r *http.Re writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive") return } + if !isOnrampAttested(rec.IdentityAssurance) { + writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "owner support actions require onramp_attested identity assurance") + return + } role := "" if principal, principalErr := a.store.getGovernancePrincipal(r.Context(), wallet); principalErr == nil && diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index 4dbb68d..f0ab352 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -221,6 +221,114 @@ func TestMembershipConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testi } } +func TestMembershipConfirmDefaultsToCryptoDirectAssurance(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + + intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ + Address: ownerAddr, + Origin: "https://edut.ai", + Locale: "en", + ChainID: cfg.ChainID, + }, http.StatusOK) + issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) + if err != nil { + t.Fatalf("parse issued_at: %v", err) + } + td := buildTypedData(cfg, designationRecord{ + Code: intentRes.DesignationCode, + DisplayToken: intentRes.DisplayToken, + Nonce: intentRes.Nonce, + IssuedAt: issuedAt, + Origin: "https://edut.ai", + }) + sig := signTypedData(t, ownerKey, td) + verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ + IntentID: intentRes.IntentID, + Address: ownerAddr, + ChainID: cfg.ChainID, + Signature: sig, + }, http.StatusOK) + + quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verifyRes.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + + confirm := postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{ + DesignationCode: verifyRes.DesignationCode, + QuoteID: quote.QuoteID, + TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + if confirm.IdentityAssurance != assuranceCryptoDirect { + t.Fatalf("expected %s assurance, got %+v", assuranceCryptoDirect, confirm) + } + + status := getJSONExpect[membershipStatusResponse](t, a, "/secret/membership/status?wallet="+ownerAddr, http.StatusOK) + if status.IdentityAssurance != assuranceCryptoDirect { + t.Fatalf("expected status assurance %s, got %+v", assuranceCryptoDirect, status) + } +} + +func TestMembershipConfirmAcceptsOnrampAttestationAssurance(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + + intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ + Address: ownerAddr, + Origin: "https://edut.ai", + Locale: "en", + ChainID: cfg.ChainID, + }, http.StatusOK) + issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) + if err != nil { + t.Fatalf("parse issued_at: %v", err) + } + td := buildTypedData(cfg, designationRecord{ + Code: intentRes.DesignationCode, + DisplayToken: intentRes.DisplayToken, + Nonce: intentRes.Nonce, + IssuedAt: issuedAt, + Origin: "https://edut.ai", + }) + sig := signTypedData(t, ownerKey, td) + verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ + IntentID: intentRes.IntentID, + Address: ownerAddr, + ChainID: cfg.ChainID, + Signature: sig, + }, http.StatusOK) + + quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verifyRes.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + + confirm := postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{ + DesignationCode: verifyRes.DesignationCode, + QuoteID: quote.QuoteID, + TxHash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Address: ownerAddr, + ChainID: cfg.ChainID, + IdentityAssurance: assuranceOnrampAttested, + IdentityAttestedBy: "moonpay", + IdentityAttestationID: "onramp-session-123", + }, http.StatusOK) + if confirm.IdentityAssurance != assuranceOnrampAttested || confirm.IdentityAttestedBy != "moonpay" { + t.Fatalf("unexpected attested confirm response: %+v", confirm) + } +} + func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() @@ -329,6 +437,61 @@ func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { } } +func TestGovernanceInstallTokenRequiresOnrampAssurance(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedMembershipWithAssurance(context.Background(), a.store, ownerAddr, assuranceCryptoDirect); err != nil { + t.Fatalf("seed membership: %v", err) + } + now := time.Now().UTC() + if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{ + EntitlementID: "ent:install:workspace-core:assurance", + QuoteID: "seed-q-install-workspace-core-assurance", + OfferID: offerIDWorkspaceCore, + Wallet: ownerAddr, + OrgRootID: "org_root_assurance", + PrincipalID: "principal_owner_assurance", + PrincipalRole: "org_root_owner", + State: "active", + AccessClass: "connected", + AvailabilityState: "active", + PolicyHash: "sha256:testpolicy", + IssuedAt: now, + TxHash: "0xabababababababababababababababababababababababababababababababab", + }); err != nil { + t.Fatalf("seed workspace core entitlement: %v", err) + } + if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{ + Wallet: ownerAddr, + OrgRootID: "org_root_assurance", + PrincipalID: "principal_owner_assurance", + PrincipalRole: "org_root_owner", + EntitlementID: "ent:install:workspace-core:assurance", + EntitlementStatus: "active", + AccessClass: "connected", + AvailabilityState: "active", + UpdatedAt: now, + }); err != nil { + t.Fatalf("seed owner principal: %v", err) + } + + errResp := postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{ + Wallet: ownerAddr, + OrgRootID: "org_root_assurance", + PrincipalID: "principal_owner_assurance", + PrincipalRole: "org_root_owner", + DeviceID: "macstudio-assurance", + LauncherVersion: "0.1.0", + Platform: "macos", + }, http.StatusForbidden) + if code := errResp["code"]; code != "identity_assurance_insufficient" { + t.Fatalf("expected identity_assurance_insufficient, got %+v", errResp) + } +} + func TestGovernanceLeaseAndOfflineRenew(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() @@ -891,22 +1054,37 @@ func newTestApp(t *testing.T) (*app, Config, func()) { } func seedActiveMembership(ctx context.Context, st *store, wallet string) error { + return seedMembershipWithAssurance(ctx, st, wallet, assuranceOnrampAttested) +} + +func seedMembershipWithAssurance(ctx context.Context, st *store, wallet, assurance string) error { now := time.Now().UTC() + assurance = normalizeAssuranceLevel(assurance) + attestedBy := "" + var attestedAt *time.Time + if assurance == assuranceOnrampAttested { + attestedBy = "test-onramp" + attestedAt = &now + } return st.putDesignation(ctx, designationRecord{ - Code: "1234567890123", - DisplayToken: "1234-5678-9012-3", - IntentID: "intent-seeded", - Nonce: "nonce-seeded", - Origin: "https://edut.ai", - Locale: "en", - Address: wallet, - ChainID: 84532, - IssuedAt: now.Add(-1 * time.Hour), - ExpiresAt: now.Add(1 * time.Hour), - VerifiedAt: &now, - MembershipStatus: "active", - MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ActivatedAt: &now, + Code: "1234567890123", + DisplayToken: "1234-5678-9012-3", + IntentID: "intent-seeded", + Nonce: "nonce-seeded", + Origin: "https://edut.ai", + Locale: "en", + Address: wallet, + ChainID: 84532, + IssuedAt: now.Add(-1 * time.Hour), + ExpiresAt: now.Add(1 * time.Hour), + VerifiedAt: &now, + MembershipStatus: "active", + MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ActivatedAt: &now, + IdentityAssurance: assurance, + IdentityAttestedBy: attestedBy, + IdentityAttestationID: "test-attestation", + IdentityAttestedAt: attestedAt, }) } diff --git a/backend/secretapi/assurance.go b/backend/secretapi/assurance.go new file mode 100644 index 0000000..2e6445e --- /dev/null +++ b/backend/secretapi/assurance.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +const ( + assuranceNone = "none" + assuranceSponsoredUnattested = "sponsored_unattested" + assuranceCryptoDirect = "crypto_direct_unattested" + assuranceOnrampAttested = "onramp_attested" +) + +func normalizeAssuranceLevel(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case assuranceNone, assuranceSponsoredUnattested, assuranceCryptoDirect, assuranceOnrampAttested: + return strings.ToLower(strings.TrimSpace(value)) + default: + return assuranceNone + } +} + +func defaultAssuranceForSponsorshipMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "self": + return assuranceCryptoDirect + case "sponsored", "sponsored_company": + return assuranceSponsoredUnattested + default: + return assuranceNone + } +} + +func resolveMembershipAssurance(quote quoteRecord, req membershipConfirmRequest, now time.Time) (string, string, string, *time.Time, error) { + level := defaultAssuranceForSponsorshipMode(quote.SponsorshipMode) + attestedBy := "" + attestationID := "" + var attestedAt *time.Time + + requestedLevel := normalizeAssuranceLevel(req.IdentityAssurance) + if requestedLevel != assuranceNone { + if requestedLevel == assuranceOnrampAttested { + if !strings.EqualFold(strings.TrimSpace(quote.SponsorshipMode), "self") { + return "", "", "", nil, fmt.Errorf("onramp_attested assurance requires self-paid quote") + } + attestedBy = strings.TrimSpace(req.IdentityAttestedBy) + attestationID = strings.TrimSpace(req.IdentityAttestationID) + if attestedBy == "" { + return "", "", "", nil, fmt.Errorf("identity_attested_by required for onramp_attested assurance") + } + level = assuranceOnrampAttested + attestedAt = &now + } else if requestedLevel != level { + return "", "", "", nil, fmt.Errorf("identity assurance mismatch for sponsorship mode") + } + } + + return level, attestedBy, attestationID, attestedAt, nil +} + +func isOnrampAttested(level string) bool { + return strings.EqualFold(strings.TrimSpace(level), assuranceOnrampAttested) +} diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index 1719066..c3bddb5 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -581,7 +581,11 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re } if quote.MembershipIncluded { - if err := a.ensureMembershipActiveForWallet(r.Context(), wallet, quote.ConfirmedTxHash); err != nil { + assurance := assuranceCryptoDirect + if strings.TrimSpace(quote.PayerWallet) != "" && !strings.EqualFold(strings.TrimSpace(quote.PayerWallet), wallet) { + assurance = assuranceSponsoredUnattested + } + if err := a.ensureMembershipActiveForWallet(r.Context(), wallet, quote.ConfirmedTxHash, assurance); err != nil { writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to activate membership") return } @@ -686,7 +690,7 @@ func (a *app) resolveMembershipStatusForWallet(r *http.Request, wallet string) ( return status, nil } -func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHash string) error { +func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHash, assurance string) error { rec, err := a.store.getDesignationByAddress(ctx, wallet) if err != nil { if !errors.Is(err, errNotFound) { @@ -706,24 +710,26 @@ func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHas return err } rec = designationRecord{ - Code: code, - DisplayToken: displayToken, - IntentID: intentID, - Nonce: nonce, - Origin: "edut-launcher", - Locale: "en", - Address: wallet, - ChainID: a.cfg.ChainID, - IssuedAt: now, - ExpiresAt: now.Add(a.cfg.IntentTTL), - VerifiedAt: &now, - MembershipStatus: "none", + Code: code, + DisplayToken: displayToken, + IntentID: intentID, + Nonce: nonce, + Origin: "edut-launcher", + Locale: "en", + Address: wallet, + ChainID: a.cfg.ChainID, + IssuedAt: now, + ExpiresAt: now.Add(a.cfg.IntentTTL), + VerifiedAt: &now, + MembershipStatus: "none", + IdentityAssurance: assuranceNone, } } now := time.Now().UTC() rec.MembershipStatus = "active" rec.MembershipTxHash = strings.ToLower(strings.TrimSpace(txHash)) rec.ActivatedAt = &now + rec.IdentityAssurance = normalizeAssuranceLevel(assurance) return a.store.putDesignation(ctx, rec) } diff --git a/backend/secretapi/models.go b/backend/secretapi/models.go index 8b7de2a..5ee2442 100644 --- a/backend/secretapi/models.go +++ b/backend/secretapi/models.go @@ -63,42 +63,55 @@ type membershipQuoteResponse struct { } type membershipConfirmRequest struct { - DesignationCode string `json:"designation_code"` - QuoteID string `json:"quote_id"` - TxHash string `json:"tx_hash"` - Address string `json:"address"` - ChainID int64 `json:"chain_id"` + DesignationCode string `json:"designation_code"` + QuoteID string `json:"quote_id"` + TxHash string `json:"tx_hash"` + Address string `json:"address"` + ChainID int64 `json:"chain_id"` + IdentityAssurance string `json:"identity_assurance_level,omitempty"` + IdentityAttestedBy string `json:"identity_attested_by,omitempty"` + IdentityAttestationID string `json:"identity_attestation_id,omitempty"` } type membershipConfirmResponse struct { - Status string `json:"status"` - DesignationCode string `json:"designation_code"` - DisplayToken string `json:"display_token"` - TxHash string `json:"tx_hash"` - ActivatedAt string `json:"activated_at"` + Status string `json:"status"` + DesignationCode string `json:"designation_code"` + DisplayToken string `json:"display_token"` + TxHash string `json:"tx_hash"` + ActivatedAt string `json:"activated_at"` + IdentityAssurance string `json:"identity_assurance_level"` + IdentityAttestedBy string `json:"identity_attested_by,omitempty"` + IdentityAttestationID string `json:"identity_attestation_id,omitempty"` } type membershipStatusResponse struct { - Status string `json:"status"` - Wallet string `json:"wallet,omitempty"` - DesignationCode string `json:"designation_code,omitempty"` + Status string `json:"status"` + Wallet string `json:"wallet,omitempty"` + DesignationCode string `json:"designation_code,omitempty"` + IdentityAssurance string `json:"identity_assurance_level,omitempty"` + IdentityAttestedBy string `json:"identity_attested_by,omitempty"` + IdentityAttestationID string `json:"identity_attestation_id,omitempty"` } type designationRecord struct { - Code string - DisplayToken string - IntentID string - Nonce string - Origin string - Locale string - Address string - ChainID int64 - IssuedAt time.Time - ExpiresAt time.Time - VerifiedAt *time.Time - MembershipStatus string - MembershipTxHash string - ActivatedAt *time.Time + Code string + DisplayToken string + IntentID string + Nonce string + Origin string + Locale string + Address string + ChainID int64 + IssuedAt time.Time + ExpiresAt time.Time + VerifiedAt *time.Time + MembershipStatus string + MembershipTxHash string + ActivatedAt *time.Time + IdentityAssurance string + IdentityAttestedBy string + IdentityAttestationID string + IdentityAttestedAt *time.Time } type quoteRecord struct { @@ -177,6 +190,7 @@ type governanceInstallStatusResponse struct { PrincipalID string `json:"principal_id,omitempty"` PrincipalRole string `json:"principal_role,omitempty"` MembershipStatus string `json:"membership_status"` + IdentityAssurance string `json:"identity_assurance_level"` EntitlementStatus string `json:"entitlement_status"` AccessClass string `json:"access_class"` AvailabilityState string `json:"availability_state"` diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go index 0304c42..02ed93a 100644 --- a/backend/secretapi/store.go +++ b/backend/secretapi/store.go @@ -53,7 +53,11 @@ func (s *store) migrate(ctx context.Context) error { verified_at TEXT, membership_status TEXT NOT NULL DEFAULT 'none', membership_tx_hash TEXT, - activated_at TEXT + activated_at TEXT, + identity_assurance_level TEXT NOT NULL DEFAULT 'none', + identity_attested_by TEXT, + identity_attestation_id TEXT, + identity_attested_at TEXT );`, `CREATE INDEX IF NOT EXISTS idx_designations_intent ON designations(intent_id);`, `CREATE INDEX IF NOT EXISTS idx_designations_address ON designations(address);`, @@ -242,6 +246,18 @@ func (s *store) migrate(ctx context.Context) error { if err := s.ensureColumn(ctx, "quotes", "sponsor_org_root_id", "TEXT"); err != nil { return err } + if err := s.ensureColumn(ctx, "designations", "identity_assurance_level", "TEXT NOT NULL DEFAULT 'none'"); err != nil { + return err + } + if err := s.ensureColumn(ctx, "designations", "identity_attested_by", "TEXT"); err != nil { + return err + } + if err := s.ensureColumn(ctx, "designations", "identity_attestation_id", "TEXT"); err != nil { + return err + } + if err := s.ensureColumn(ctx, "designations", "identity_attested_at", "TEXT"); err != nil { + return err + } if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_to", "TEXT"); err != nil { return err } @@ -288,8 +304,8 @@ func (s *store) ensureColumn(ctx context.Context, table, column, columnDef strin func (s *store) putDesignation(ctx context.Context, rec designationRecord) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO designations ( - code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(code) DO UPDATE SET display_token=excluded.display_token, intent_id=excluded.intent_id, @@ -303,14 +319,18 @@ func (s *store) putDesignation(ctx context.Context, rec designationRecord) error verified_at=excluded.verified_at, membership_status=excluded.membership_status, membership_tx_hash=excluded.membership_tx_hash, - activated_at=excluded.activated_at - `, rec.Code, rec.DisplayToken, rec.IntentID, rec.Nonce, rec.Origin, rec.Locale, rec.Address, rec.ChainID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(rec.VerifiedAt), strings.ToLower(rec.MembershipStatus), nullableString(rec.MembershipTxHash), formatNullableTime(rec.ActivatedAt)) + activated_at=excluded.activated_at, + identity_assurance_level=excluded.identity_assurance_level, + identity_attested_by=excluded.identity_attested_by, + identity_attestation_id=excluded.identity_attestation_id, + identity_attested_at=excluded.identity_attested_at + `, rec.Code, rec.DisplayToken, rec.IntentID, rec.Nonce, rec.Origin, rec.Locale, rec.Address, rec.ChainID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(rec.VerifiedAt), strings.ToLower(rec.MembershipStatus), nullableString(rec.MembershipTxHash), formatNullableTime(rec.ActivatedAt), normalizeAssuranceLevel(rec.IdentityAssurance), nullableString(rec.IdentityAttestedBy), nullableString(rec.IdentityAttestationID), formatNullableTime(rec.IdentityAttestedAt)) return err } func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (designationRecord, error) { row := s.db.QueryRowContext(ctx, ` - SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at + SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at FROM designations WHERE intent_id = ? `, intentID) @@ -319,7 +339,7 @@ func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (de func (s *store) getDesignationByCode(ctx context.Context, code string) (designationRecord, error) { row := s.db.QueryRowContext(ctx, ` - SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at + SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at FROM designations WHERE code = ? `, code) @@ -328,7 +348,7 @@ func (s *store) getDesignationByCode(ctx context.Context, code string) (designat func (s *store) getDesignationByAddress(ctx context.Context, address string) (designationRecord, error) { row := s.db.QueryRowContext(ctx, ` - SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at + SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at FROM designations WHERE address = ? ORDER BY issued_at DESC @@ -339,9 +359,9 @@ func (s *store) getDesignationByAddress(ctx context.Context, address string) (de func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecord, error) { var rec designationRecord - var issued, expires, verified, activated sql.NullString - var membershipTx sql.NullString - err := row.Scan(&rec.Code, &rec.DisplayToken, &rec.IntentID, &rec.Nonce, &rec.Origin, &rec.Locale, &rec.Address, &rec.ChainID, &issued, &expires, &verified, &rec.MembershipStatus, &membershipTx, &activated) + var issued, expires, verified, activated, identityAttestedAt sql.NullString + var membershipTx, identityAssurance, identityAttestedBy, identityAttestationID sql.NullString + err := row.Scan(&rec.Code, &rec.DisplayToken, &rec.IntentID, &rec.Nonce, &rec.Origin, &rec.Locale, &rec.Address, &rec.ChainID, &issued, &expires, &verified, &rec.MembershipStatus, &membershipTx, &activated, &identityAssurance, &identityAttestedBy, &identityAttestationID, &identityAttestedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return designationRecord{}, errNotFound @@ -353,6 +373,10 @@ func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecor rec.VerifiedAt = parseRFC3339Ptr(verified) rec.MembershipTxHash = membershipTx.String rec.ActivatedAt = parseRFC3339Ptr(activated) + rec.IdentityAssurance = normalizeAssuranceLevel(identityAssurance.String) + rec.IdentityAttestedBy = identityAttestedBy.String + rec.IdentityAttestationID = identityAttestationID.String + rec.IdentityAttestedAt = parseRFC3339Ptr(identityAttestedAt) return rec, nil } diff --git a/docs/api/examples/governance-installer.examples.md b/docs/api/examples/governance-installer.examples.md index dcefa35..f53a545 100644 --- a/docs/api/examples/governance-installer.examples.md +++ b/docs/api/examples/governance-installer.examples.md @@ -93,6 +93,7 @@ Authorization: Bearer "principal_id": "human.joshua", "principal_role": "org_root_owner", "membership_status": "active", + "identity_assurance_level": "onramp_attested", "entitlement_status": "active", "access_class": "connected", "availability_state": "active", diff --git a/docs/api/examples/secret-system.examples.md b/docs/api/examples/secret-system.examples.md index f8fddc3..8cd910f 100644 --- a/docs/api/examples/secret-system.examples.md +++ b/docs/api/examples/secret-system.examples.md @@ -145,7 +145,10 @@ Request: "quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W", "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", - "chain_id": 8453 + "chain_id": 8453, + "identity_assurance_level": "onramp_attested", + "identity_attested_by": "moonpay", + "identity_attestation_id": "mp_session_01JAA..." } ``` @@ -157,7 +160,10 @@ Success (`200`): "designation_code": "0217073045482", "display_token": "0217-0730-4548-2", "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "activated_at": "2026-02-17T07:33:09Z" + "activated_at": "2026-02-17T07:33:09Z", + "identity_assurance_level": "onramp_attested", + "identity_attested_by": "moonpay", + "identity_attestation_id": "mp_session_01JAA..." } ``` @@ -183,7 +189,10 @@ Success (`200`): { "status": "active", "wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", - "designation_code": "0217073045482" + "designation_code": "0217073045482", + "identity_assurance_level": "onramp_attested", + "identity_attested_by": "moonpay", + "identity_attestation_id": "mp_session_01JAA..." } ``` diff --git a/docs/api/governance-installer.openapi.yaml b/docs/api/governance-installer.openapi.yaml index 7b3acae..db0f3c1 100644 --- a/docs/api/governance-installer.openapi.yaml +++ b/docs/api/governance-installer.openapi.yaml @@ -260,6 +260,7 @@ components: required: - wallet - membership_status + - identity_assurance_level - entitlement_status - activation_status properties: @@ -275,6 +276,9 @@ components: membership_status: type: string enum: [active, none, suspended, revoked, unknown] + identity_assurance_level: + type: string + enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested] entitlement_status: type: string enum: [active, none, suspended, revoked, unknown] diff --git a/docs/api/secret-system.openapi.yaml b/docs/api/secret-system.openapi.yaml index 0d751ca..9ba0829 100644 --- a/docs/api/secret-system.openapi.yaml +++ b/docs/api/secret-system.openapi.yaml @@ -234,9 +234,18 @@ components: pattern: '^0x[a-fA-F0-9]{40}$' chain_id: type: integer + identity_assurance_level: + type: string + enum: [crypto_direct_unattested, sponsored_unattested, onramp_attested] + identity_attested_by: + type: string + description: Optional provider identifier when assurance level is onramp_attested. + identity_attestation_id: + type: string + description: Optional provider attestation reference id. MembershipConfirmResponse: type: object - required: [status, designation_code, display_token, tx_hash, activated_at] + required: [status, designation_code, display_token, tx_hash, activated_at, identity_assurance_level] properties: status: type: string @@ -250,6 +259,13 @@ components: activated_at: type: string format: date-time + identity_assurance_level: + type: string + enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested] + identity_attested_by: + type: string + identity_attestation_id: + type: string MembershipStatusResponse: type: object required: [status] @@ -261,3 +277,10 @@ components: type: string designation_code: type: string + identity_assurance_level: + type: string + enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested] + identity_attested_by: + type: string + identity_attestation_id: + type: string