feat(ucca): adopt obligation_id + harden join to semantic (step 3 core)
The Obligation Registry filled proposed_obligation_id (7/7) + cut the logging family (obligations 47->66). Adopted obligation_id onto our 7 accepted CRA->OWASP mappings; the join now prefers the EXACT obligation_id over the coarse citation_unit (which stays as fallback for not-yet-adopted rows). Effect: semantic coverage 2->4 (user_authentication_required, credential_confidentiality_protection, auth_key_management, event_logging_security_events). Befund 1 resolved: V11.2.1 crypto now sits under credential_confidentiality_protection, not user_authentication_required. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,13 @@
|
|||||||
// Reviewt 2026-06-25 (benjamin): 7 accepted, 13 rejected. accepted = Audit-Wahrheit (Advisor nutzt acceptedOnly).
|
// Reviewt 2026-06-25 (benjamin): 7 accepted, 13 rejected. accepted = Audit-Wahrheit (Advisor nutzt acceptedOnly).
|
||||||
// rejected bleiben als Audit-Spur ("warum verworfen"). KEIN confidence — kuratiert = fachliche Feststellung.
|
// rejected bleiben als Audit-Spur ("warum verworfen"). KEIN confidence — kuratiert = fachliche Feststellung.
|
||||||
// Architekturbeweis: CRA -> OWASP fuer AppSec/Auth/Crypto/Logging; Ops/Update/Attack-Surface/Integritaet -> NIST/BSI.
|
// Architekturbeweis: CRA -> OWASP fuer AppSec/Auth/Crypto/Logging; Ops/Update/Attack-Surface/Integritaet -> NIST/BSI.
|
||||||
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.3.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.3.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.2.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11 = Cryptography.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11 = Cryptography, richtiger Bereich fuer Verschluesselung.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.2.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11 = Cryptography.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11 = Cryptography, richtiger Bereich fuer Verschluesselung.", "version": "2026-06-25", "obligation_id": "credential_confidentiality_protection"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.7.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11.7 = Key Management.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11.7 = Key Management fuer Verschluesselung/Schluesselverwaltung.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.7.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11.7 = Key Management.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11.7 = Key Management fuer Verschluesselung/Schluesselverwaltung.", "version": "2026-06-25", "obligation_id": "auth_key_management"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.3", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.3", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.4", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.4", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, kein Auth — verworfen.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, kein Auth — verworfen.", "version": "2026-06-25"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.3.2", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.3.2", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestAssessObligationStatus(t *testing.T) {
|
|||||||
joins, maps, ev := loadGraph(t)
|
joins, maps, ev := loadGraph(t)
|
||||||
|
|
||||||
// covered obligation, no evidence collected yet (MVP) -> offen
|
// covered obligation, no evidence collected yet (MVP) -> offen
|
||||||
st := AssessObligationStatus(joins, maps, ev, "firmware_software_authentication", nil)
|
st := AssessObligationStatus(joins, maps, ev, "user_authentication_required", nil)
|
||||||
if st.Status != "offen" {
|
if st.Status != "offen" {
|
||||||
t.Errorf("want offen, got %q", st.Status)
|
t.Errorf("want offen, got %q", st.Status)
|
||||||
}
|
}
|
||||||
@@ -35,14 +35,14 @@ func TestAssessObligationStatus(t *testing.T) {
|
|||||||
t.Error("MVP: all required evidence should be missing")
|
t.Error("MVP: all required evidence should be missing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Logf("DURCHSTICH firmware_software_authentication: status=%s legal_basis=%v citation_spans=%s",
|
t.Logf("DURCHSTICH user_authentication_required: status=%s legal_basis=%v citation_spans=%s",
|
||||||
st.Status, st.LegalBasis, st.CitationSpans)
|
st.Status, st.LegalBasis, st.CitationSpans)
|
||||||
for _, c := range st.Controls {
|
for _, c := range st.Controls {
|
||||||
t.Logf(" %s %s (%s): %d required evidence, %d missing", c.Framework, c.Control, c.MappingType, len(c.RequiredEvidence), len(c.MissingEvidence))
|
t.Logf(" %s %s (%s): %d required evidence, %d missing", c.Framework, c.Control, c.MappingType, len(c.RequiredEvidence), len(c.MissingEvidence))
|
||||||
}
|
}
|
||||||
|
|
||||||
// all evidence present -> erfuellt
|
// all evidence present -> erfuellt
|
||||||
st2 := AssessObligationStatus(joins, maps, ev, "firmware_software_authentication", func(f, c, et string) bool { return true })
|
st2 := AssessObligationStatus(joins, maps, ev, "user_authentication_required", func(f, c, et string) bool { return true })
|
||||||
if st2.Status != "erfuellt" {
|
if st2.Status != "erfuellt" {
|
||||||
t.Errorf("want erfuellt with all evidence present, got %q", st2.Status)
|
t.Errorf("want erfuellt with all evidence present, got %q", st2.Status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,9 +75,52 @@ func (o *ObligationJoinKeys) ObligationsForCitation(citationRef string) []string
|
|||||||
return o.byCitationKey[citationUnitKey(citationRef)]
|
return o.byCitationKey[citationUnitKey(citationRef)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObligationCoverage is one row of the cross-session coverage report: for a registry
|
// FindObligation returns the registry entry for an obligation_id (nil if unknown).
|
||||||
// obligation, which of our accepted controls reach it (via the citation_unit join), how much
|
func (o *ObligationJoinKeys) FindObligation(obligationID string) *ObligationKey {
|
||||||
// evidence they require, and the resulting coverage status.
|
for i := range o.ObligationIDs {
|
||||||
|
if o.ObligationIDs[i].ObligationID == obligationID {
|
||||||
|
return &o.ObligationIDs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mappingReaches reports whether a control mapping reaches an obligation — EXACT via the
|
||||||
|
// adopted obligation_id (semantic, preferred), else via the interim citation_unit join (for
|
||||||
|
// not-yet-adopted rows). Once obligation_id is set, the coarse citation_unit match is ignored:
|
||||||
|
// that is how the semantic join replaces the structural one (e.g. V11.2.1 crypto no longer
|
||||||
|
// rides (2)(d) into user_authentication_required — it goes to credential_confidentiality_protection).
|
||||||
|
func mappingReaches(m ControlMapping, ob ObligationKey, citationKeys map[string]bool) bool {
|
||||||
|
if m.ObligationID != "" {
|
||||||
|
return m.ObligationID == ob.ObligationID
|
||||||
|
}
|
||||||
|
return citationKeys[citationUnitKey(m.SourceNorm)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptedControlsForObligation returns our accepted control mappings that reach an obligation
|
||||||
|
// (deduped by target control), obligation_id-exact where adopted, citation_unit otherwise.
|
||||||
|
func AcceptedControlsForObligation(ob ObligationKey, mappings *ControlMappingSet) []ControlMapping {
|
||||||
|
keys := make(map[string]bool, len(ob.CitationUnits))
|
||||||
|
for _, cu := range ob.CitationUnits {
|
||||||
|
keys[citationUnitKey(cu)] = true
|
||||||
|
}
|
||||||
|
out := []ControlMapping{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range mappings.All {
|
||||||
|
if !m.IsAccepted() || !mappingReaches(m, ob, keys) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ck := m.TargetFramework + ":" + m.TargetControl
|
||||||
|
if seen[ck] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ck] = true
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObligationCoverage is one row of the cross-session coverage report.
|
||||||
type ObligationCoverage struct {
|
type ObligationCoverage struct {
|
||||||
ObligationID string `json:"obligation_id"`
|
ObligationID string `json:"obligation_id"`
|
||||||
Family string `json:"family"`
|
Family string `json:"family"`
|
||||||
@@ -86,52 +129,33 @@ type ObligationCoverage struct {
|
|||||||
EvidenceCount int `json:"evidence_count"`
|
EvidenceCount int `json:"evidence_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputeObligationCoverage joins the Registry obligations to our accepted control mappings
|
// ComputeObligationCoverage joins the Registry obligations to our control mappings — exact via
|
||||||
// (via citation_unit) and reports per obligation: covered (>=1 accepted control reaches it),
|
// obligation_id where adopted, else via the interim citation_unit join — and reports per
|
||||||
// mapped_rejected (only rejected mappings reach it), or uncovered (no mapping reaches it).
|
// obligation: covered (>=1 accepted control reaches it), mapped_rejected (only rejected
|
||||||
// This is the signal back to the Obligation session for what to cut/refine next.
|
// mappings reach it), or uncovered. The signal back to the Obligation session.
|
||||||
func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet) []ObligationCoverage {
|
func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet) []ObligationCoverage {
|
||||||
type bucket struct {
|
|
||||||
acceptedCtrls map[string]bool
|
|
||||||
rejected bool
|
|
||||||
}
|
|
||||||
byKey := map[string]*bucket{}
|
|
||||||
for _, m := range mappings.All {
|
|
||||||
k := citationUnitKey(m.SourceNorm)
|
|
||||||
b := byKey[k]
|
|
||||||
if b == nil {
|
|
||||||
b = &bucket{acceptedCtrls: map[string]bool{}}
|
|
||||||
byKey[k] = b
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case m.IsAccepted():
|
|
||||||
b.acceptedCtrls[m.TargetFramework+":"+m.TargetControl] = true
|
|
||||||
case m.MappingStatus == "rejected":
|
|
||||||
b.rejected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]ObligationCoverage, 0, len(joins.ObligationIDs))
|
out := make([]ObligationCoverage, 0, len(joins.ObligationIDs))
|
||||||
for _, ob := range joins.ObligationIDs {
|
for _, ob := range joins.ObligationIDs {
|
||||||
|
keys := make(map[string]bool, len(ob.CitationUnits))
|
||||||
|
for _, cu := range ob.CitationUnits {
|
||||||
|
keys[citationUnitKey(cu)] = true
|
||||||
|
}
|
||||||
cov := ObligationCoverage{ObligationID: ob.ObligationID, Family: ob.Family}
|
cov := ObligationCoverage{ObligationID: ob.ObligationID, Family: ob.Family}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
rejected := false
|
rejected := false
|
||||||
for _, cu := range ob.CitationUnits {
|
for _, m := range mappings.All {
|
||||||
b := byKey[citationUnitKey(cu)]
|
if !mappingReaches(m, ob, keys) {
|
||||||
if b == nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rejected = rejected || b.rejected
|
if m.IsAccepted() {
|
||||||
for ck := range b.acceptedCtrls {
|
ck := m.TargetFramework + ":" + m.TargetControl
|
||||||
if seen[ck] {
|
if !seen[ck] {
|
||||||
continue
|
seen[ck] = true
|
||||||
}
|
cov.AcceptedControls = append(cov.AcceptedControls, ck)
|
||||||
seen[ck] = true
|
cov.EvidenceCount += len(evidence.RequiredFor(m.TargetFramework, m.TargetControl))
|
||||||
cov.AcceptedControls = append(cov.AcceptedControls, ck)
|
|
||||||
fwCtrl := strings.SplitN(ck, ":", 2)
|
|
||||||
if len(fwCtrl) == 2 {
|
|
||||||
cov.EvidenceCount += len(evidence.RequiredFor(fwCtrl[0], fwCtrl[1]))
|
|
||||||
}
|
}
|
||||||
|
} else if m.MappingStatus == "rejected" {
|
||||||
|
rejected = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
@@ -146,36 +170,3 @@ func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappi
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptedControlsForObligation returns our accepted control mappings that reach an
|
|
||||||
// obligation via the interim citation_unit join (deduped by target control).
|
|
||||||
func AcceptedControlsForObligation(ob ObligationKey, mappings *ControlMappingSet) []ControlMapping {
|
|
||||||
keys := make(map[string]bool, len(ob.CitationUnits))
|
|
||||||
for _, cu := range ob.CitationUnits {
|
|
||||||
keys[citationUnitKey(cu)] = true
|
|
||||||
}
|
|
||||||
out := []ControlMapping{}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range mappings.All {
|
|
||||||
if !m.IsAccepted() || !keys[citationUnitKey(m.SourceNorm)] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ck := m.TargetFramework + ":" + m.TargetControl
|
|
||||||
if seen[ck] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[ck] = true
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindObligation returns the registry entry for an obligation_id (nil if unknown).
|
|
||||||
func (o *ObligationJoinKeys) FindObligation(obligationID string) *ObligationKey {
|
|
||||||
for i := range o.ObligationIDs {
|
|
||||||
if o.ObligationIDs[i].ObligationID == obligationID {
|
|
||||||
return &o.ObligationIDs[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user