Skip to content

Commit c95ebaf

Browse files
authored
Merge pull request #261 from aojea/harden
feat(security): enforce region via control-plane-attested biscuit facts
2 parents b23e9b2 + bda7e72 commit c95ebaf

25 files changed

Lines changed: 921 additions & 62 deletions

api/datalog.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@ const (
198198
// Example Datalog: service("mcp", "calculator")
199199
FactService = "service"
200200

201+
// FactRegion is the control-plane-attested jurisdiction of the token's node,
202+
// following the hierarchical model of region.go. The control plane mints one
203+
// fact per hierarchy level (RegionPrefixes), so a requirement is a single
204+
// exact match: a node attested as "EU-DE" carries region("EU") and
205+
// region("EU-DE"), satisfying `check if region("EU")` but never a finer
206+
// requirement it cannot guarantee. Distinct from LabelRegion, the
207+
// unauthenticated gossip routing hint.
208+
// Contains: biscuit.String(regionPrefix)
209+
// Example Datalog: check if region("EU")
210+
FactRegion = "region"
211+
201212
// FactTime defines the current system time injected during evaluation.
202213
// Contains: biscuit.Date(currentTime)
203214
// Example Datalog: check if time($time)
@@ -420,6 +431,40 @@ func BuildTargetDatalogFact(targetStr string) biscuit.Fact {
420431
}}
421432
}
422433

434+
// RegionFacts materializes a region claim as one Datalog fact per hierarchy
435+
// level (see FactRegion and RegionPrefixes). An empty region returns nil.
436+
func RegionFacts(region string) []biscuit.Fact {
437+
prefixes := RegionPrefixes(region)
438+
if len(prefixes) == 0 {
439+
return nil
440+
}
441+
facts := make([]biscuit.Fact, 0, len(prefixes))
442+
for _, p := range prefixes {
443+
facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{
444+
Name: FactRegion,
445+
IDs: []biscuit.Term{biscuit.String(p)},
446+
}})
447+
}
448+
return facts
449+
}
450+
451+
// RegionCheck compiles required regions (canonical, pre-validated with
452+
// ValidateRegion) into a single fail-closed check satisfied when the token
453+
// carries any of them: `check if region("EU") or region("NA-US")`.
454+
func RegionCheck(required []string) (biscuit.Check, error) {
455+
if len(required) == 0 {
456+
return biscuit.Check{}, fmt.Errorf("no required regions")
457+
}
458+
clauses := make([]string, 0, len(required))
459+
for _, r := range required {
460+
if err := ValidateRegion(r); err != nil {
461+
return biscuit.Check{}, err
462+
}
463+
clauses = append(clauses, fmt.Sprintf("%s(%q)", FactRegion, NormalizeRegion(r)))
464+
}
465+
return parser.FromStringCheck("check if " + strings.Join(clauses, " or "))
466+
}
467+
423468
// isExactService reports whether serviceStr resolves to a plain exact-match grant, as opposed to a
424469
// wildcard/prefix/suffix pattern which already collapses to a single, cheap fact via BuildServiceDatalogFact.
425470
func isExactService(serviceStr string) (svcType, svcName string, exact bool) {

api/datalog_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,3 +556,65 @@ func TestBuildTargetDatalogFacts(t *testing.T) {
556556
})
557557
}
558558
}
559+
560+
func TestRegionFactsAndCheck(t *testing.T) {
561+
pub, priv := makeKeyPair(t)
562+
563+
if facts := RegionFacts(""); facts != nil {
564+
t.Errorf("RegionFacts(\"\") = %v, want nil", facts)
565+
}
566+
if _, err := RegionCheck(nil); err == nil {
567+
t.Error("RegionCheck(nil): expected error, got nil")
568+
}
569+
if _, err := RegionCheck([]string{"MARS"}); err == nil {
570+
t.Error("RegionCheck(MARS): expected error, got nil")
571+
}
572+
573+
tests := []struct {
574+
name string
575+
claimed string // minted into the token via RegionFacts
576+
required []string
577+
expectAllow bool
578+
}{
579+
{"finer claim satisfies coarser requirement", "EU-DE-BY", []string{"EU"}, true},
580+
{"exact level match", "EU-DE", []string{"EU-DE"}, true},
581+
{"any-of requirement", "NA-US", []string{"EU", "NA-US"}, true},
582+
{"lowercase requirement is normalized", "EU-DE", []string{"eu"}, true},
583+
{"coarser claim never satisfies finer requirement", "EU", []string{"EU-DE"}, false},
584+
{"disjoint region", "NA-US", []string{"EU"}, false},
585+
{"unattested token fails closed", "", []string{"EU"}, false},
586+
}
587+
588+
for _, tt := range tests {
589+
t.Run(tt.name, func(t *testing.T) {
590+
builder := biscuit.NewBuilder(priv)
591+
for _, fact := range RegionFacts(tt.claimed) {
592+
if err := builder.AddAuthorityFact(fact); err != nil {
593+
t.Fatalf("failed to add region fact: %v", err)
594+
}
595+
}
596+
tok, err := builder.Build()
597+
if err != nil {
598+
t.Fatalf("failed to build token: %v", err)
599+
}
600+
601+
authorizer, err := tok.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(5*time.Second)))
602+
if err != nil {
603+
t.Fatalf("failed to create authorizer: %v", err)
604+
}
605+
check, err := RegionCheck(tt.required)
606+
if err != nil {
607+
t.Fatalf("RegionCheck(%v): %v", tt.required, err)
608+
}
609+
authorizer.AddCheck(check)
610+
authorizer.AddPolicy(AllowIfTruePolicy)
611+
612+
err = authorizer.Authorize()
613+
if tt.expectAllow && err != nil {
614+
t.Errorf("expected authorized, got error: %v", err)
615+
} else if !tt.expectAllow && err == nil {
616+
t.Error("expected denied, but authorization succeeded")
617+
}
618+
})
619+
}
620+
}

api/region.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ func RegionMatches(required, claimed string) bool {
114114
return claimed == required || strings.HasPrefix(claimed, required+"-")
115115
}
116116

117+
// RegionPrefixes returns the hierarchy prefix closure of a region claim in
118+
// canonical form, coarsest first: "EU-DE-BY" -> ["EU", "EU-DE", "EU-DE-BY"].
119+
// Materializing every level lets policies match a requirement with a single
120+
// exact fact lookup (see FactRegion) while keeping RegionMatches semantics:
121+
// a finer claim carries its coarser prefixes, a coarser claim never gains
122+
// finer ones. The empty string returns nil.
123+
func RegionPrefixes(s string) []string {
124+
s = NormalizeRegion(s)
125+
if s == "" {
126+
return nil
127+
}
128+
parts := strings.Split(s, "-")
129+
out := make([]string, 0, len(parts))
130+
for i := range parts {
131+
out = append(out, strings.Join(parts[:i+1], "-"))
132+
}
133+
return out
134+
}
135+
117136
// ContinentCodes returns the valid continent codes, sorted.
118137
func ContinentCodes() []string {
119138
codes := make([]string, 0, len(continentNames))

api/region_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,27 @@ func TestNormalizeRegion(t *testing.T) {
7171
t.Errorf("NormalizeRegion: got %q, want EU-DE", got)
7272
}
7373
}
74+
75+
func TestRegionPrefixes(t *testing.T) {
76+
tests := []struct {
77+
in string
78+
want []string
79+
}{
80+
{"", nil},
81+
{"EU", []string{"EU"}},
82+
{"eu-de", []string{"EU", "EU-DE"}}, // normalized
83+
{"EU-DE-BY", []string{"EU", "EU-DE", "EU-DE-BY"}},
84+
}
85+
for _, tt := range tests {
86+
got := RegionPrefixes(tt.in)
87+
if len(got) != len(tt.want) {
88+
t.Errorf("RegionPrefixes(%q) = %v, want %v", tt.in, got, tt.want)
89+
continue
90+
}
91+
for i := range got {
92+
if got[i] != tt.want[i] {
93+
t.Errorf("RegionPrefixes(%q)[%d] = %q, want %q", tt.in, i, got[i], tt.want[i])
94+
}
95+
}
96+
}
97+
}

api/sam.pb.go

Lines changed: 30 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/sam.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ message EnrollRequest {
4949
string peer_id = 2;
5050
bytes public_key = 3;
5151
string requested_role = 4;
52+
// Operator-declared region claim (CONTINENT[-COUNTRY[-ZONE]], see
53+
// api/region.go). Validated fail-closed by the control plane and, once
54+
// attested by the enrollment flow's gates, minted as signed region()
55+
// facts in the biscuit. Empty means no claim.
56+
string region = 5;
5257
}
5358

5459
message EnrollResponse {
@@ -75,6 +80,9 @@ message BootstrapEnrollRequest {
7580
string peer_id = 2;
7681
bytes public_key = 3;
7782
string requested_role = 4;
83+
// Operator-declared region claim; the admin approving the enrollment
84+
// attests it (see EnrollRequest.region).
85+
string region = 5;
7886
}
7987

8088
message BootstrapEnrollResponse {

internal/controlplane/policy_scale_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func TestPolicyScale(t *testing.T) {
114114
}
115115

116116
start = time.Now()
117-
biscuitBytes, _, err := identity.MintBiscuitToken(priv, claims, nil, nodePeer, time.Now().Add(time.Hour), roleNames, roles)
117+
biscuitBytes, _, err := identity.MintBiscuitToken(priv, claims, nil, nodePeer, time.Now().Add(time.Hour), roleNames, roles, "")
118118
mintDur := time.Since(start)
119119
if err != nil {
120120
t.Fatalf("mint failed at n=%d: %v", n, err)
@@ -200,7 +200,7 @@ func TestPolicyScaleSetEncoding(t *testing.T) {
200200

201201
claims := jwt.MapClaims{}
202202
start := time.Now()
203-
biscuitBytes, _, err := identity.MintBiscuitToken(priv, claims, nil, nodePeer, time.Now().Add(time.Hour), []string{"bulk-role"}, roles)
203+
biscuitBytes, _, err := identity.MintBiscuitToken(priv, claims, nil, nodePeer, time.Now().Add(time.Hour), []string{"bulk-role"}, roles, "")
204204
mintDur := time.Since(start)
205205
if err != nil {
206206
t.Fatalf("mint failed at n=%d exact entries: %v", n, err)
@@ -319,7 +319,7 @@ func TestPolicyScaleManyRoles(t *testing.T) {
319319

320320
claims := jwt.MapClaims{}
321321
start := time.Now()
322-
biscuitBytes, _, err := identity.MintBiscuitToken(priv, claims, nil, nodePeer, time.Now().Add(time.Hour), roleNames, roles)
322+
biscuitBytes, _, err := identity.MintBiscuitToken(priv, claims, nil, nodePeer, time.Now().Add(time.Hour), roleNames, roles, "")
323323
mintDur := time.Since(start)
324324
if err != nil {
325325
t.Fatalf("mint failed at n=%d matched roles: %v", n, err)

0 commit comments

Comments
 (0)