From e0dc04aecd3c44412b79b30903a5320731ad450b Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sun, 17 May 2026 01:54:22 -0400 Subject: [PATCH 1/2] [AI assisted] feat(event): EquivalenceLink event type for cross-anchor binding Adds TypeEquivalenceLink to the V2 event type enum (V1 stays unchanged: V1 is the byte-for-byte reference TL schema and a new event type would be a wire-shape change). Closes the cross-anchor binding gap: deployments can now record a cryptographic link between two registrations under different anchor profiles asserting the same operational entity, rather than relying solely on a client-side join on agentHost. Schema additions: - TypeEquivalenceLink Type = "EQUIVALENCE_LINK" in the V2 enum. - IsValid updated to include the new type. - New EquivalenceLink struct on Event with omitempty: LinkedAnsID (required when type == EQUIVALENCE_LINK) LinkedAnsName (optional; absent for base-only registrations) LinkedAnchorType (optional; "fqdn" / "did" / "lei") LinkedAnchorResolvedID (optional; the canonical anchor identifier) Rationale (optional; default "operator-asserted-multi-anchor") Validation rules (in Event.Validate): - EQUIVALENCE_LINK MUST carry equivalence; equivalence.linkedAnsId required and MUST differ from ansId. - EQUIVALENCE_LINK MUST NOT carry agent or attestations. - Non-link event types MUST NOT carry equivalence. Cryptographic semantics: the producer signature on the enclosing event is the link. The producer (RA) attests that ansId and linkedAnsId resolve to the same operational entity. Per-deployment authority checks (controller has rights over both registrations) are operator concerns; the reference impl provides the type and validation surface only. RA-side handler endpoint to ingest operator-initiated link assertions is a follow-up. The omitempty on Equivalence preserves byte-identical canonical output for existing AGENT_REGISTERED / AGENT_RENEWED / AGENT_REVOKED / AGENT_DEPRECATED events; leaf hashes for those types are unchanged. Tests: 11 cases covering happy path, missing-equivalence, missing-LinkedAnsID, self-reference, agent-rejected, attestations-rejected, non-link-rejects-equivalence, JSON round-trip shape, and omitempty preservation on non-link events. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/tl/event/equivalence_link_test.go | 188 +++++++++++++++++++++ internal/tl/event/event.go | 100 +++++++++-- 2 files changed, 275 insertions(+), 13 deletions(-) create mode 100644 internal/tl/event/equivalence_link_test.go diff --git a/internal/tl/event/equivalence_link_test.go b/internal/tl/event/equivalence_link_test.go new file mode 100644 index 0000000..4e10158 --- /dev/null +++ b/internal/tl/event/equivalence_link_test.go @@ -0,0 +1,188 @@ +package event + +import ( + "encoding/json" + "strings" + "testing" +) + +// validLinkEvent returns a populated EQUIVALENCE_LINK envelope that +// passes Validate (modulo signing fields). Tests build mutations on +// top of this fixture rather than re-stating the entire shape. +func validLinkEvent() *Envelope { + return &Envelope{ + SchemaVersion: SchemaVersion, + Payload: &Payload{ + LogID: "log-uuid-1", + Producer: &Producer{ + KeyID: "ans-ra-signer", + Signature: "sig-jws", + Event: &Event{ + AnsID: "primary-id", + AnsName: "ans://v1.0.0.invoicing.acme.com", + EventType: TypeEquivalenceLink, + RaID: "ans-ra-local", + Timestamp: "2026-05-17T10:00:00Z", + Equivalence: &EquivalenceLink{ + LinkedAnsID: "linked-id", + LinkedAnsName: "ans://v1.0.0.invoicing.acme.com", + LinkedAnchorType: "lei", + LinkedAnchorResolvedID: "529900T8BM49AURSDO55", + Rationale: "operator-asserted-multi-anchor", + }, + }, + }, + }, + } +} + +func TestValidate_EquivalenceLink_HappyPath(t *testing.T) { + if err := validLinkEvent().Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +func TestValidate_EquivalenceLink_MissingEquivalenceFails(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Equivalence = nil + err := env.Validate() + if err == nil { + t.Fatal("expected error when EQUIVALENCE_LINK has no equivalence") + } + if !strings.Contains(err.Error(), "EQUIVALENCE_LINK requires equivalence") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_MissingLinkedAnsIDFails(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Equivalence.LinkedAnsID = "" + err := env.Validate() + if err == nil { + t.Fatal("expected error when LinkedAnsID is empty") + } + if !strings.Contains(err.Error(), "equivalence.linkedAnsId required") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_SelfReferenceFails(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Equivalence.LinkedAnsID = env.Payload.Producer.Event.AnsID + err := env.Validate() + if err == nil { + t.Fatal("expected error when LinkedAnsID == AnsID") + } + if !strings.Contains(err.Error(), "must differ from ansId") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_AgentFieldRejected(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Agent = &Agent{Host: "x.test", Name: "x", Version: "1.0.0"} + err := env.Validate() + if err == nil { + t.Fatal("expected error when EQUIVALENCE_LINK carries agent") + } + if !strings.Contains(err.Error(), "must not carry agent") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_AttestationsRejected(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Attestations = &Attestations{DomainValidation: "ACME-DNS-01"} + err := env.Validate() + if err == nil { + t.Fatal("expected error when EQUIVALENCE_LINK carries attestations") + } + if !strings.Contains(err.Error(), "must not carry attestations") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_NonLink_RejectsEquivalence(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.EventType = TypeAgentRegistered + env.Payload.Producer.Event.Agent = &Agent{Host: "x.test", Name: "x", Version: "1.0.0"} + env.Payload.Producer.Event.Attestations = &Attestations{DomainValidation: "ACME-DNS-01"} + // Equivalence is still populated; should be rejected. + err := env.Validate() + if err == nil { + t.Fatal("expected error when AGENT_REGISTERED carries equivalence") + } + if !strings.Contains(err.Error(), "equivalence not allowed for eventType") { + t.Errorf("error: %v", err) + } +} + +func TestType_EquivalenceLink_IsValid(t *testing.T) { + if !TypeEquivalenceLink.IsValid() { + t.Error("TypeEquivalenceLink should be IsValid true") + } +} + +// TestEquivalenceLink_JSONRoundTrip exercises the envelope's JCS path: +// EQUIVALENCE_LINK serializes to JSON and deserializes back without +// shape drift. Important because the leaf hash is computed over the +// canonical bytes; any field-name change breaks downstream verifiers. +func TestEquivalenceLink_JSONRoundTrip(t *testing.T) { + env := validLinkEvent() + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + // Spot-check the canonical field names appear in the output. + for _, want := range []string{ + `"eventType":"EQUIVALENCE_LINK"`, + `"equivalence":`, + `"linkedAnsId":"linked-id"`, + `"linkedAnchorType":"lei"`, + `"linkedAnchorResolvedId":"529900T8BM49AURSDO55"`, + `"rationale":"operator-asserted-multi-anchor"`, + } { + if !strings.Contains(string(b), want) { + t.Errorf("JSON missing %s", want) + } + } + + var back Envelope + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if back.Payload.Producer.Event.Equivalence.LinkedAnsID != "linked-id" { + t.Errorf("round-trip lost LinkedAnsID: %+v", back.Payload.Producer.Event.Equivalence) + } +} + +// TestEquivalenceLink_OmitemptyOnNonLink confirms the equivalence field +// stays absent in JSON when not set, so existing AGENT_REGISTERED bytes +// stay byte-identical to pre-change. +func TestEquivalenceLink_OmitemptyOnNonLink(t *testing.T) { + env := &Envelope{ + SchemaVersion: SchemaVersion, + Payload: &Payload{ + LogID: "log-uuid-2", + Producer: &Producer{ + KeyID: "ans-ra-signer", + Signature: "sig", + Event: &Event{ + AnsID: "primary", + AnsName: "ans://v1.0.0.acme.com", + EventType: TypeAgentRegistered, + Timestamp: "2026-05-17T10:00:00Z", + Agent: &Agent{Host: "acme.com", Name: "acme", Version: "1.0.0"}, + }, + }, + }, + } + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if strings.Contains(string(b), `"equivalence"`) { + t.Errorf("equivalence key should be absent on non-link events; got: %s", b) + } +} diff --git a/internal/tl/event/event.go b/internal/tl/event/event.go index 6da66b7..6b3706a 100644 --- a/internal/tl/event/event.go +++ b/internal/tl/event/event.go @@ -86,6 +86,12 @@ const ( // TypeAgentDeprecated — the agent was superseded by a newer // version and is scheduled for rotation. TypeAgentDeprecated Type = "AGENT_DEPRECATED" + + // TypeEquivalenceLink — two registrations share an operational + // identity. Carries Equivalence in place of Agent / Attestations. + // See ANS-0 §7 cross-anchor coexistence and the EquivalenceLink + // struct below. + TypeEquivalenceLink Type = "EQUIVALENCE_LINK" ) // IsValid reports whether t is a recognized event type. @@ -94,7 +100,8 @@ func (t Type) IsValid() bool { case TypeAgentRegistered, TypeAgentRenewed, TypeAgentRevoked, - TypeAgentDeprecated: + TypeAgentDeprecated, + TypeEquivalenceLink: return true default: return false @@ -130,18 +137,66 @@ type Producer struct { // Event is the producer-authored payload. The producer JCS-canonicalizes // this and signs it; the resulting detached JWS lands in Producer.Signature. type Event struct { - AnsID string `json:"ansId"` - AnsName string `json:"ansName"` - EventType Type `json:"eventType"` - Agent *Agent `json:"agent,omitempty"` - Attestations *Attestations `json:"attestations,omitempty"` - ExpiresAt string `json:"expiresAt,omitempty"` - IssuedAt string `json:"issuedAt,omitempty"` - RaID string `json:"raId,omitempty"` - RenewalStatus string `json:"renewalStatus,omitempty"` - RevocationReasonCode string `json:"revocationReasonCode,omitempty"` - RevokedAt string `json:"revokedAt,omitempty"` - Timestamp string `json:"timestamp"` // RFC3339, required + AnsID string `json:"ansId"` + AnsName string `json:"ansName"` + EventType Type `json:"eventType"` + Agent *Agent `json:"agent,omitempty"` + Attestations *Attestations `json:"attestations,omitempty"` + Equivalence *EquivalenceLink `json:"equivalence,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + IssuedAt string `json:"issuedAt,omitempty"` + RaID string `json:"raId,omitempty"` + RenewalStatus string `json:"renewalStatus,omitempty"` + RevocationReasonCode string `json:"revocationReasonCode,omitempty"` + RevokedAt string `json:"revokedAt,omitempty"` + Timestamp string `json:"timestamp"` // RFC3339, required +} + +// EquivalenceLink records that two registrations share an operational +// identity. Recorded under EventType=EQUIVALENCE_LINK; the AnsID at +// the top of the event is the *primary* registration making the +// assertion, and the LinkedAnsID is the *other* registration the +// primary is asserting equivalence with. +// +// The producer signature on the enclosing event is the cryptographic +// link: a verifier reading the event sees the producer (the RA) attest +// that "AnsID and LinkedAnsID resolve to the same operational entity." +// The producer's authority to make that claim derives from the RA's +// position as the registrar of both sides; deployments enforce that +// authority by validating that the asserting controller has authority +// over both registrations before signing. +// +// The reference impl provides the type and validation; emission paths +// and per-deployment authority checks are operator concerns. +type EquivalenceLink struct { + // LinkedAnsID is the agentId of the linked registration (the one + // asserted to be the same operational entity as the event's + // primary AnsID). Required. + LinkedAnsID string `json:"linkedAnsId"` + + // LinkedAnsName is the linked registration's ANSName when the + // linked anchor produces one; empty for non-FQDN anchors that + // register base-only. Optional. + LinkedAnsName string `json:"linkedAnsName,omitempty"` + + // LinkedAnchorType reports the linked registration's anchor type + // ("fqdn", "did", "lei"). Optional but recommended; lets verifiers + // filter equivalence-graph edges by anchor type without joining + // against the registrations themselves. + LinkedAnchorType string `json:"linkedAnchorType,omitempty"` + + // LinkedAnchorResolvedID is the linked registration's canonical + // anchor identifier (e.g., the LEI string, the DID URI, the FQDN + // for an FQDN anchor). Optional but recommended for the same + // reason as LinkedAnchorType. + LinkedAnchorResolvedID string `json:"linkedAnchorResolvedId,omitempty"` + + // Rationale records why the link is being asserted. The default + // "operator-asserted-multi-anchor" applies when the operator + // registered the same operational entity under multiple anchors + // and is now publishing the cryptographic link. Other values are + // deployment-specific and not normatively constrained here. + Rationale string `json:"rationale,omitempty"` } // Agent identifies which agent the event refers to. @@ -309,6 +364,25 @@ func (ev *Event) Validate() error { if _, err := time.Parse(time.RFC3339, ev.Timestamp); err != nil { return fmt.Errorf("event: timestamp must be RFC3339: %w", err) } + if ev.EventType == TypeEquivalenceLink { + if ev.Equivalence == nil { + return errors.New("event: EQUIVALENCE_LINK requires equivalence") + } + if ev.Equivalence.LinkedAnsID == "" { + return errors.New("event: equivalence.linkedAnsId required") + } + if ev.Equivalence.LinkedAnsID == ev.AnsID { + return errors.New("event: equivalence.linkedAnsId must differ from ansId") + } + if ev.Agent != nil { + return errors.New("event: EQUIVALENCE_LINK must not carry agent") + } + if ev.Attestations != nil { + return errors.New("event: EQUIVALENCE_LINK must not carry attestations") + } + } else if ev.Equivalence != nil { + return fmt.Errorf("event: equivalence not allowed for eventType %q", ev.EventType) + } return nil } From 08b3c575f5cd1caa420d552d1d8d8e838310dd8f Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sun, 17 May 2026 11:20:44 -0400 Subject: [PATCH 2/2] [AI assisted] feat(ra): EquivalenceLink TL append handler Lands the handler that turns the schema PR #20 added into a useful endpoint. POST /v2/ans/agents/{agentId}/equivalence-links emits one EQUIVALENCE_LINK event into the TL linking the path agent to a linked agent named in the body. Authorization is the simplest defensible shape: the same authenticated operator must own both registrations on this RA. The writeOwnership middleware confirms the primary; the service re-checks ownership of the linked agent inside (404-shaped to preserve existence-hiding for agents owned by others). Service-layer LinkEquivalence rejects empty owner/primary/linked, self-links, missing-linked, non-active linked, and propagates store errors. Inner-event builder leaves Agent and Attestations nil and populates Equivalence per the schema; the existing outbox + TL append path reuses unchanged because v2Codec.ParseAndBuild already calls Validate() which has accepted EQUIVALENCE_LINK since PR #20. A future amendment may admit federated link events that span two RAs with two distinct producer signatures, requiring an Envelope schema cosigner block. Within one RA the existing producer signature is enough. Tests: unit coverage for anchor recovery + inner-event shape (FQDN/LEI, with/without AnchorClaim). Live integration verified against the demo stack: register two agents under one operator, POST link, observe outbox-worker delivery EQUIVALENCE_LINK leafIndex 2 in the TL, plus 422/404 rejection paths for self-link, missing linkedAnsId, and non-existent linked agent. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/ans-ra/main.go | 8 + internal/ra/handler/equivalence_link.go | 104 ++++++++++ internal/ra/service/equivalence_link.go | 208 +++++++++++++++++++ internal/ra/service/equivalence_link_test.go | 157 ++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 internal/ra/handler/equivalence_link.go create mode 100644 internal/ra/service/equivalence_link.go create mode 100644 internal/ra/service/equivalence_link_test.go diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index 510cb02..eab23c7 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -215,6 +215,14 @@ func run(cfgPath string) error { r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/identity", lifeH.SubmitIdentityCSR) r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/server", lifeH.SubmitServerCSR) + // EquivalenceLink (PR #20 schema; this PR's handler). Cross-anchor + // binding event: the caller must own both the primary {agentId} + // and the linked agentId in the body. The writeOwnership + // middleware confirms ownership of the primary; the handler + // re-checks ownership of the linked agent inside the service. + linkH := handler.NewEquivalenceLinkHandler(regSvc) + r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/equivalence-links", linkH.CreateLink) + // Server certificate renewal routes. r.With(readOwnership).Get("/v2/ans/agents/{agentId}/certificates/server/renewal", lifeH.GetServerCertRenewal) r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/server/renewal", lifeH.SubmitServerCertRenewal) diff --git a/internal/ra/handler/equivalence_link.go b/internal/ra/handler/equivalence_link.go new file mode 100644 index 0000000..263e2cb --- /dev/null +++ b/internal/ra/handler/equivalence_link.go @@ -0,0 +1,104 @@ +// Package handler: equivalence-link HTTP handler. +// +// Mounts at POST /v2/ans/agents/{agentId}/equivalence-links and emits +// one EQUIVALENCE_LINK event into the Transparency Log. The path +// {agentId} is the primary registration; the body carries the linked +// registration's agent id plus an optional rationale string. +// +// Auth: the writeOwnership middleware confirms the caller owns the +// primary registration before this handler runs. The service then +// re-checks ownership on the linked agent so a caller cannot link +// their own registration to one they do not control. +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/godaddy/ans/internal/adapter/auth" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/ra/service" +) + +// EquivalenceLinkHandler exposes the link-emission endpoint. It owns +// no state; all logic lives in service.RegistrationService.LinkEquivalence. +type EquivalenceLinkHandler struct { + svc *service.RegistrationService +} + +// NewEquivalenceLinkHandler wires the handler against a registration +// service. Returned value is safe to share across request goroutines. +func NewEquivalenceLinkHandler(svc *service.RegistrationService) *EquivalenceLinkHandler { + return &EquivalenceLinkHandler{svc: svc} +} + +// linkRequest is the JSON shape the handler accepts on the body. +// linkedAnsId is the linked registration's agent UUID; rationale is +// the operator's free-text justification persisted in the link event. +type linkRequest struct { + LinkedAnsID string `json:"linkedAnsId"` + Rationale string `json:"rationale,omitempty"` +} + +// linkResponse mirrors what the service returns plus the path-derived +// primary agent id. Callers use the returned anchor type and value +// to display the link to operators without re-fetching the linked +// registration. +type linkResponse struct { + PrimaryAgentID string `json:"primaryAgentId"` + PrimaryAnsName string `json:"primaryAnsName,omitempty"` + LinkedAnsID string `json:"linkedAnsId"` + LinkedAnsName string `json:"linkedAnsName,omitempty"` + LinkedAnchorType string `json:"linkedAnchorType"` + LinkedAnchorValue string `json:"linkedAnchorValue"` + Rationale string `json:"rationale,omitempty"` + Timestamp string `json:"timestamp"` +} + +// CreateLink handles POST /v2/ans/agents/{agentId}/equivalence-links. +func (h *EquivalenceLinkHandler) CreateLink(w http.ResponseWriter, r *http.Request) { + primaryID := chi.URLParam(r, "agentId") + if primaryID == "" { + WriteError(w, domain.NewValidationError("MISSING_AGENT_ID", "agentId is required")) + return + } + + var body linkRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&body); err != nil { + WriteError(w, domain.NewValidationError("BAD_JSON", "request body is not valid JSON: "+err.Error())) + return + } + + id, ok := auth.IdentityFromContext(r.Context()) + if !ok || id.Subject == "" { + WriteError(w, domain.NewValidationError("MISSING_OWNER", "owner identity is required")) + return + } + ownerID := id.Subject + + res, err := h.svc.LinkEquivalence(r.Context(), service.LinkEquivalenceInput{ + OwnerID: ownerID, + PrimaryAgentID: primaryID, + LinkedAgentID: body.LinkedAnsID, + Rationale: body.Rationale, + }) + if err != nil { + WriteError(w, err) + return + } + + WriteJSON(w, http.StatusCreated, linkResponse{ + PrimaryAgentID: res.PrimaryAgentID, + PrimaryAnsName: res.PrimaryAnsName, + LinkedAnsID: res.LinkedAgentID, + LinkedAnsName: res.LinkedAnsName, + LinkedAnchorType: res.LinkedAnchorType, + LinkedAnchorValue: res.LinkedAnchorValue, + Rationale: res.Rationale, + Timestamp: res.Timestamp, + }) +} diff --git a/internal/ra/service/equivalence_link.go b/internal/ra/service/equivalence_link.go new file mode 100644 index 0000000..497cec4 --- /dev/null +++ b/internal/ra/service/equivalence_link.go @@ -0,0 +1,208 @@ +// Package service: equivalence-link emission. +// +// Implements the RA-side handler for the EQUIVALENCE_LINK event type +// schema in internal/tl/event. An operator running multiple agents +// under different anchor profiles (FQDN + LEI, FQDN + did:web, etc.) +// asserts that two of those registrations refer to the same operator +// by emitting one link event into the Transparency Log. +// +// Authorization model. The simplest defensible auth shape: the same +// authenticated operator must own both registrations on this RA. The +// caller's identity arrives through the existing ownership middleware +// (which gates the path-{agentId} primary registration), and this +// service performs a second ownership check on the linked agent. A +// future amendment may admit federated link events that span two RAs +// with two distinct producer signatures, in which case the Envelope +// schema grows a cosigner block; for now, both registrations live on +// this RA and the RA's existing producer key signs the single envelope. +package service + +import ( + "context" + "fmt" + "time" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/tl/event" +) + +// LinkEquivalenceInput collects what the handler needs to pass into +// the service layer. Both agent IDs are RA-issued UUIDs; rationale is +// the operator's plain-language reason a downstream verifier will see +// in the TL-sealed event. +type LinkEquivalenceInput struct { + OwnerID string // authenticated operator (from ownership middleware) + PrimaryAgentID string // {agentId} from the request path + LinkedAgentID string // body field linkedAnsId + Rationale string // optional free-text justification +} + +// LinkEquivalenceResult is what the handler returns to the caller. +type LinkEquivalenceResult struct { + PrimaryAgentID string + PrimaryAnsName string + LinkedAgentID string + LinkedAnsName string + LinkedAnchorType string + LinkedAnchorValue string + Rationale string + Timestamp string +} + +// LinkEquivalence emits one EQUIVALENCE_LINK event linking two +// registrations the caller owns on this RA. Returns 403-shaped +// validation errors on auth failures, 404-shaped on missing linked +// agent, 422 on a self-link attempt or non-ACTIVE linked agent. +func (s *RegistrationService) LinkEquivalence( + ctx context.Context, in LinkEquivalenceInput, +) (*LinkEquivalenceResult, error) { + if in.OwnerID == "" { + return nil, domain.NewValidationError("MISSING_OWNER", "owner id is required") + } + if in.PrimaryAgentID == "" { + return nil, domain.NewValidationError("MISSING_PRIMARY_AGENT_ID", "primary agentId is required") + } + if in.LinkedAgentID == "" { + return nil, domain.NewValidationError("MISSING_LINKED_AGENT_ID", "linkedAnsId is required") + } + if in.PrimaryAgentID == in.LinkedAgentID { + return nil, domain.NewValidationError( + "EQUIVALENCE_SELF_LINK", + "primary and linked agents must differ; cannot link a registration to itself", + ) + } + + // The ownership middleware has already loaded primary by path id, + // confirmed primary.OwnerID matches the caller, and would have + // returned 403 before we got here. We re-fetch primary anyway + // because the service path is also reachable from internal callers + // that bypass the middleware (admin tooling, future federation). + primary, err := s.agents.FindByAgentID(ctx, in.PrimaryAgentID) + if err != nil { + return nil, err + } + if primary == nil { + return nil, domain.NewValidationError( + "AGENT_NOT_FOUND", + fmt.Sprintf("primary agent %s not found", in.PrimaryAgentID), + ) + } + if primary.OwnerID != in.OwnerID { + return nil, domain.NewValidationError( + "NOT_AUTHORIZED", + "caller does not own the primary registration", + ) + } + + linked, err := s.agents.FindByAgentID(ctx, in.LinkedAgentID) + if err != nil { + return nil, err + } + if linked == nil { + return nil, domain.NewValidationError( + "LINKED_AGENT_NOT_FOUND", + fmt.Sprintf("linked agent %s not found on this RA", in.LinkedAgentID), + ) + } + if linked.OwnerID != in.OwnerID { + // Returning a not-found-shaped error preserves the existence- + // hiding posture the read endpoints use; the caller cannot + // probe for agents owned by other operators. + return nil, domain.NewValidationError( + "LINKED_AGENT_NOT_FOUND", + fmt.Sprintf("linked agent %s not found on this RA", in.LinkedAgentID), + ) + } + if linked.Status != domain.StatusActive { + return nil, domain.NewValidationError( + "LINKED_AGENT_NOT_ACTIVE", + fmt.Sprintf("linked agent %s is not ACTIVE (status=%s)", in.LinkedAgentID, linked.Status), + ) + } + + now := s.now() + inner := s.equivalenceInnerEvent(primary, linked, in.Rationale, now) + if err := inner.Validate(); err != nil { + // Belt-and-suspenders: the inner-event validator is the schema + // gate. Surfacing the failure here keeps the misshapen event + // off the outbox. + return nil, domain.NewValidationError("INVALID_EVENT", err.Error()) + } + + if err := s.enqueueTLEvent(ctx, string(event.TypeEquivalenceLink), primary, inner, now); err != nil { + return nil, fmt.Errorf("enqueue equivalence link: %w", err) + } + + return &LinkEquivalenceResult{ + PrimaryAgentID: primary.AgentID, + PrimaryAnsName: primary.AnsName.String(), + LinkedAgentID: linked.AgentID, + LinkedAnsName: linked.AnsName.String(), + LinkedAnchorType: anchorTypeFromRegistration(linked), + LinkedAnchorValue: anchorValueFromRegistration(linked), + Rationale: in.Rationale, + Timestamp: now.UTC().Format(time.RFC3339), + }, nil +} + +// equivalenceInnerEvent builds the TL inner event for a link. Unlike +// baseInnerEvent, this builder leaves Agent and Attestations nil and +// populates Equivalence per the EQUIVALENCE_LINK schema. The emitted +// shape passes internal/tl/event.Validate's link-event branch. +func (s *RegistrationService) equivalenceInnerEvent( + primary, linked *domain.AgentRegistration, rationale string, now time.Time, +) *event.Event { + raID := "" + if s.signer != nil { + raID = s.signer.RaID + } + return &event.Event{ + AnsID: primary.AgentID, + AnsName: primary.AnsName.String(), + EventType: event.TypeEquivalenceLink, + // No Agent block: the linked event documents an existing + // registration relationship, not an agent's own facts. + // No Attestations: domain-control proofs apply per + // registration, not per link. + Equivalence: &event.EquivalenceLink{ + LinkedAnsID: linked.AgentID, + LinkedAnsName: linked.AnsName.String(), + LinkedAnchorType: anchorTypeFromRegistration(linked), + LinkedAnchorResolvedID: anchorValueFromRegistration(linked), + Rationale: rationale, + }, + RaID: raID, + IssuedAt: now.UTC().Format(time.RFC3339), + Timestamp: now.UTC().Format(time.RFC3339), + } +} + +// anchorTypeFromRegistration recovers the anchor profile id that +// produced this registration. Plan G persists AnchorClaim on the +// aggregate when present; pre-Plan-G rows surface as nil and +// register implicitly under FQDN, so the absence is treated as fqdn. +func anchorTypeFromRegistration(reg *domain.AgentRegistration) string { + if reg.AnchorClaim != nil && reg.AnchorClaim.AnchorType != "" { + return string(reg.AnchorClaim.AnchorType) + } + return "fqdn" +} + +// anchorValueFromRegistration recovers the canonical anchor input. +// For FQDN the value is the agent host; for did:* / lei the value is +// the resolved id stored on the AnchorClaim aggregate. +func anchorValueFromRegistration(reg *domain.AgentRegistration) string { + if reg.AnchorClaim != nil && reg.AnchorClaim.ResolvedID != "" { + return reg.AnchorClaim.ResolvedID + } + return reg.AgentHost +} + +// now returns the service's current time. Wraps the tested clock +// accessor so equivalenceInnerEvent stays a pure function. +func (s *RegistrationService) now() time.Time { + if s.clock != nil { + return s.clock() + } + return time.Now() +} diff --git a/internal/ra/service/equivalence_link_test.go b/internal/ra/service/equivalence_link_test.go new file mode 100644 index 0000000..e147896 --- /dev/null +++ b/internal/ra/service/equivalence_link_test.go @@ -0,0 +1,157 @@ +package service + +import ( + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/tl/event" +) + +// mustAnsName parses a string into a domain.AnsName or fails the test. +func mustAnsName(t *testing.T, s string) domain.AnsName { + t.Helper() + n, err := domain.ParseAnsName(s) + if err != nil { + t.Fatalf("parse ans name %q: %v", s, err) + } + return n +} + +// Unit tests for the link-emission helpers. End-to-end coverage runs +// against the live demo stack in scripts/demo (see PR description for +// the live test plan). The store-coupled paths (LinkEquivalence +// itself) are exercised through that integration; these unit tests +// pin the anchor recovery + inner-event shape in isolation so a +// future field rename surfaces here. + +func TestAnchorTypeFromRegistration_FQDNDefault(t *testing.T) { + reg := &domain.AgentRegistration{AgentHost: "agent.test"} + if got := anchorTypeFromRegistration(reg); got != "fqdn" { + t.Errorf("got %q, want fqdn (default for missing AnchorClaim)", got) + } +} + +func TestAnchorTypeFromRegistration_LEI(t *testing.T) { + reg := &domain.AgentRegistration{ + AgentHost: "agent.test", + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + } + if got := anchorTypeFromRegistration(reg); got != "lei" { + t.Errorf("got %q, want lei", got) + } +} + +func TestAnchorValueFromRegistration_LEI(t *testing.T) { + reg := &domain.AgentRegistration{ + AgentHost: "ignored.test", + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + } + if got := anchorValueFromRegistration(reg); got != "529900T8BM49AURSDO55" { + t.Errorf("got %q, want LEI string from ResolvedID", got) + } +} + +func TestAnchorValueFromRegistration_FQDNFallsBackToHost(t *testing.T) { + reg := &domain.AgentRegistration{AgentHost: "agent.test"} + if got := anchorValueFromRegistration(reg); got != "agent.test" { + t.Errorf("got %q, want agent.test (AgentHost fallback)", got) + } +} + +func TestEquivalenceInnerEvent_ShapeValidates(t *testing.T) { + // The link inner event MUST pass internal/tl/event.Validate's + // link-event branch: present Equivalence, no Agent, no + // Attestations, non-empty linkedAnsId differing from ansId. + primary := &domain.AgentRegistration{ + AgentID: "primary-uuid", + AgentHost: "primary.acme.com", + AnsName: mustAnsName(t, "ans://v1.0.0.primary.acme.com"), + } + linked := &domain.AgentRegistration{ + AgentID: "linked-uuid", + AgentHost: "ignored.test", + AnsName: mustAnsName(t, "ans://v1.0.0.linked.acme.com"), + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + } + svc := &RegistrationService{ + signer: &EventSigner{RaID: "ans-ra-test"}, + clock: func() time.Time { return time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC) }, + } + now := svc.clock() + + inner := svc.equivalenceInnerEvent(primary, linked, "operator-asserted", now) + if err := inner.Validate(); err != nil { + t.Fatalf("inner event Validate: %v", err) + } + + if inner.EventType != event.TypeEquivalenceLink { + t.Errorf("EventType: got %q, want EQUIVALENCE_LINK", inner.EventType) + } + if inner.Agent != nil { + t.Error("Agent block must be absent on link events") + } + if inner.Attestations != nil { + t.Error("Attestations must be absent on link events") + } + if inner.Equivalence == nil { + t.Fatal("Equivalence block must be present on link events") + } + if inner.Equivalence.LinkedAnsID != "linked-uuid" { + t.Errorf("LinkedAnsID: got %q", inner.Equivalence.LinkedAnsID) + } + if inner.Equivalence.LinkedAnchorType != "lei" { + t.Errorf("LinkedAnchorType: got %q", inner.Equivalence.LinkedAnchorType) + } + if inner.Equivalence.LinkedAnchorResolvedID != "529900T8BM49AURSDO55" { + t.Errorf("LinkedAnchorResolvedID: got %q", inner.Equivalence.LinkedAnchorResolvedID) + } + if inner.Equivalence.Rationale != "operator-asserted" { + t.Errorf("Rationale: got %q", inner.Equivalence.Rationale) + } + if inner.RaID != "ans-ra-test" { + t.Errorf("RaID: got %q", inner.RaID) + } + if inner.IssuedAt == "" || inner.Timestamp == "" { + t.Error("IssuedAt and Timestamp must be set") + } +} + +func TestEquivalenceInnerEvent_FQDNToFQDN(t *testing.T) { + // Same-anchor-type link still validates; the schema does not + // require the two anchors to differ. + primary := &domain.AgentRegistration{ + AgentID: "p", + AgentHost: "p.test", + AnsName: mustAnsName(t, "ans://v1.0.0.p.test"), + } + linked := &domain.AgentRegistration{ + AgentID: "l", + AgentHost: "l.test", + AnsName: mustAnsName(t, "ans://v1.0.0.l.test"), + } + svc := &RegistrationService{ + signer: &EventSigner{RaID: "ans-ra-test"}, + clock: func() time.Time { return time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC) }, + } + inner := svc.equivalenceInnerEvent(primary, linked, "", svc.clock()) + if err := inner.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + if inner.Equivalence.LinkedAnchorType != "fqdn" { + t.Errorf("LinkedAnchorType: got %q, want fqdn", inner.Equivalence.LinkedAnchorType) + } + if inner.Equivalence.LinkedAnchorResolvedID != "l.test" { + t.Errorf("LinkedAnchorResolvedID: got %q, want l.test (host fallback)", + inner.Equivalence.LinkedAnchorResolvedID) + } +}