Add membership assurance levels and policy gates
This commit is contained in:
parent
0696762d24
commit
7f00494456
@ -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
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
65
backend/secretapi/assurance.go
Normal file
65
backend/secretapi/assurance.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -93,6 +93,7 @@ Authorization: Bearer <wallet-session>
|
||||
"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",
|
||||
|
||||
@ -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..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user