Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions api/datalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,94 @@ const (
// Example Datalog: service("mcp", "calculator")
FactService = "service"

// Request facts. The verifying node injects them from the request on the
// wire, never from the token, so a holder cannot assert them. They are
// present when the node handles the request as HTTP, and absent on a
// stream that carries no HTTP request.

// FactMethod is the HTTP method of the request as received, or "CONNECT"
// for a tunnel the node opens without terminating HTTP.
// Contains: biscuit.String(method)
// Example Datalog: deny if method($m), !($m == "GET")
FactMethod = "method"

// FactPath is the request path as the backend sees it: leading slash, no
// query, with the mesh routing prefix removed. A tunnel carries path("").
// Contains: biscuit.String(path)
// Example Datalog: deny if path($p), !$p.starts_with("/v2/public/")
FactPath = "path"

// FactHost is the destination hostname of an egress request, lowercase,
// as authorized: the same string as the service name.
// Contains: biscuit.String(host)
// Example Datalog: deny if host("payroll.internal.example.com")
FactHost = "host"

// FactPort is the destination port of an egress request.
// Contains: biscuit.Integer(port)
// Example Datalog: deny if port($p), !($p == 443)
FactPort = "port"

// HTTP narrowing (PolicyRole.http). A narrowed allowed_services entry is
// minted as an http_granted_service_* fact instead of the plain
// granted_service_* one, together with the methods and paths it permits.
// BaselineHTTPRules derive the plain grant only for a request whose
// method() and path() facts match, so the ordinary allow policies apply
// unchanged and a request without those facts matches nothing.
//
// Every fact below is keyed by ($type, $key), where $key is the term the
// corresponding granted_service_* fact would carry: the exact name, the
// suffix with its leading dot, the prefix with its trailing dot, or "*".

// FactHTTPGrantedServiceExact is granted_service_exact, narrowed.
// Contains: biscuit.String(serviceType), biscuit.String(serviceName)
FactHTTPGrantedServiceExact = "http_granted_service_exact"

// FactHTTPGrantedServiceSuffix is granted_service_suffix, narrowed.
// Contains: biscuit.String(serviceType), biscuit.String(suffixPattern)
FactHTTPGrantedServiceSuffix = "http_granted_service_suffix"

// FactHTTPGrantedServicePrefix is granted_service_prefix, narrowed.
// Contains: biscuit.String(serviceType), biscuit.String(prefixPattern)
FactHTTPGrantedServicePrefix = "http_granted_service_prefix"

// FactHTTPGrantedServiceAll is granted_service_all, narrowed. Its key is "*".
// Contains: biscuit.String(serviceType)
FactHTTPGrantedServiceAll = "http_granted_service_all"

// FactHTTPGrantedServiceAllTypes is granted_service_all_types, narrowed.
// Its type and key are both "*".
// Contains: biscuit.Bool(true) (marker fact)
FactHTTPGrantedServiceAllTypes = "http_granted_service_all_types"

// FactGrantedMethod lists the methods a narrowed grant permits.
// Contains: biscuit.String(serviceType), biscuit.String(key), biscuit.Set of biscuit.String(method)
FactGrantedMethod = "granted_method"

// FactGrantedMethodAny marks a narrowed grant that permits every method.
// Contains: biscuit.String(serviceType), biscuit.String(key)
FactGrantedMethodAny = "granted_method_any"

// FactGrantedPathExact lists the exact paths a narrowed grant permits.
// Contains: biscuit.String(serviceType), biscuit.String(key), biscuit.Set of biscuit.String(path)
FactGrantedPathExact = "granted_path_exact"

// FactGrantedPathPrefix permits every path under one prefix, one fact per prefix.
// Contains: biscuit.String(serviceType), biscuit.String(key), biscuit.String(prefix)
FactGrantedPathPrefix = "granted_path_prefix"

// FactGrantedPathAny marks a narrowed grant that permits every path.
// Contains: biscuit.String(serviceType), biscuit.String(key)
FactGrantedPathAny = "granted_path_any"

// FactHTTPMethodOK is derived when the request method satisfies a narrowed grant.
// Contains: biscuit.String(serviceType), biscuit.String(key)
FactHTTPMethodOK = "http_method_ok"

// FactHTTPPathOK is derived when the request path satisfies a narrowed grant.
// Contains: biscuit.String(serviceType), biscuit.String(key)
FactHTTPPathOK = "http_path_ok"

// FactLabel is a control-plane-attested key=value label on the token's
// node (see api/labels.go). The control plane mints one fact per
// declared label, so a requirement is a single exact match: a node
Expand Down Expand Up @@ -338,6 +426,11 @@ var (
// BaselineRules are the pre-compiled target evaluation rules for the node middleware.
BaselineRules []biscuit.Rule

// BaselineHTTPRules derive the plain granted_service_* facts from the
// http_granted_service_* facts of a narrowed grant when the request's
// method() and path() satisfy it. Added wherever BaselineRules are.
BaselineHTTPRules []biscuit.Rule

// BaselineReplayCheck verifies that the client peer ID matches the connection peer ID.
BaselineReplayCheck biscuit.Check

Expand Down Expand Up @@ -375,6 +468,8 @@ type DatalogSources struct {
Policies []string `json:"policies"`
// Rules derive allow_network_target from target grants (BaselineRules).
Rules []string `json:"rules"`
// HTTPRules derive service grants from narrowed grants (BaselineHTTPRules).
HTTPRules []string `json:"http_rules"`
// AgentRules derive agent_authorized from agent grants (BaselineAgentRules).
AgentRules []string `json:"agent_rules"`
// TargetFactRules map identity facts to target_fact (TargetFactRules).
Expand Down Expand Up @@ -458,6 +553,33 @@ func init() {
BaselineRules = append(BaselineRules, r)
}

// 2b. HTTP Narrowing Rules (PolicyRole.http).
// A narrowed grant yields the plain granted_service_* fact only when the
// request's method and path match, so the allow policies above decide as
// they do for any grant. Both axes must hold; an axis with no restriction
// carries the *_any marker. The rules are positive, so a request with no
// method() or path() fact derives nothing and a narrowed grant fails closed.
BaselineSources.HTTPRules = []string{
fmt.Sprintf(`%s($t, $k) <- %s($m), %s($t, $k, $set), $set.contains($m)`, FactHTTPMethodOK, FactMethod, FactGrantedMethod),
fmt.Sprintf(`%s($t, $k) <- %s($t, $k)`, FactHTTPMethodOK, FactGrantedMethodAny),
fmt.Sprintf(`%s($t, $k) <- %s($p), %s($t, $k, $set), $set.contains($p)`, FactHTTPPathOK, FactPath, FactGrantedPathExact),
fmt.Sprintf(`%s($t, $k) <- %s($p), %s($t, $k, $prefix), $p.starts_with($prefix)`, FactHTTPPathOK, FactPath, FactGrantedPathPrefix),
fmt.Sprintf(`%s($t, $k) <- %s($t, $k)`, FactHTTPPathOK, FactGrantedPathAny),
fmt.Sprintf(`%s($t, $n) <- %s($t, $n), %s($t, $n), %s($t, $n), %s($t, $n)`, FactGrantedServiceExact, FactService, FactHTTPGrantedServiceExact, FactHTTPMethodOK, FactHTTPPathOK),
fmt.Sprintf(`%s($t, $s) <- %s($t, $n), %s($t, $s), $n.ends_with($s), %s($t, $s), %s($t, $s)`, FactGrantedServiceSuffix, FactService, FactHTTPGrantedServiceSuffix, FactHTTPMethodOK, FactHTTPPathOK),
fmt.Sprintf(`%s($t, $p) <- %s($t, $n), %s($t, $p), $n.starts_with($p), %s($t, $p), %s($t, $p)`, FactGrantedServicePrefix, FactService, FactHTTPGrantedServicePrefix, FactHTTPMethodOK, FactHTTPPathOK),
fmt.Sprintf(`%s($t) <- %s($t, $n), %s($t), %s($t, "*"), %s($t, "*")`, FactGrantedServiceAll, FactService, FactHTTPGrantedServiceAll, FactHTTPMethodOK, FactHTTPPathOK),
fmt.Sprintf(`%s(true) <- %s($t, $n), %s(true), %s("*", "*"), %s("*", "*")`, FactGrantedServiceAllTypes, FactService, FactHTTPGrantedServiceAllTypes, FactHTTPMethodOK, FactHTTPPathOK),
}

for i, rStr := range BaselineSources.HTTPRules {
r, err := parser.FromStringRule(rStr)
if err != nil {
panic(fmt.Sprintf("failed to parse baseline http rule %d: %v", i, err))
}
BaselineHTTPRules = append(BaselineHTTPRules, r)
}

var err error

// BaselineReplayCheck prevents token theft/replay by ensuring the client_peer_id fact (embedded by the control plane during issuance)
Expand Down
189 changes: 189 additions & 0 deletions api/egress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"fmt"
"net/url"
"regexp"
"strings"

"github.com/biscuit-auth/biscuit-go/v2"
)

// credentialNameSyntax bounds a credential name to one safe file name: the
// serving node resolves it under its secrets directory, so it must not be
// able to name a path.
var credentialNameSyntax = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$`)

// ValidateEgressDestination checks one PolicyConfig.egress entry. roleNames
// are the roles the same document defines, so served_by can be checked
// against them; a label entry is checked for form only.
func ValidateEgressDestination(d *EgressDestination, roleNames map[string]bool) error {
if d == nil {
return fmt.Errorf("egress entry is nil")
}
if err := ValidateEgressName(d.GetName()); err != nil {
return err
}
if d.GetTargetUrl() != "" {
if err := validateEgressTargetURL(d.GetTargetUrl()); err != nil {
return fmt.Errorf("egress %q: %w", d.GetName(), err)
}
}
if d.GetCredential() != "" && !credentialNameSyntax.MatchString(d.GetCredential()) {
return fmt.Errorf("egress %q: credential %q must be a name of 1-64 chars of [a-zA-Z0-9_.-], not a path or a value", d.GetName(), d.GetCredential())
}
if len(d.GetServedBy()) == 0 {
return fmt.Errorf("egress %q: served_by must select at least one role or label", d.GetName())
}
for _, sel := range d.GetServedBy() {
if key, value, isLabel := strings.Cut(sel, "="); isLabel {
if err := ValidateLabelKey(key); err != nil {
return fmt.Errorf("egress %q: served_by %q: %w", d.GetName(), sel, err)
}
if err := ValidateLabelValue(value); err != nil {
return fmt.Errorf("egress %q: served_by %q: %w", d.GetName(), sel, err)
}
continue
}
if !roleNames[sel] {
return fmt.Errorf("egress %q: served_by %q is neither a role in this policy nor a key=value label", d.GetName(), sel)
}
}
return nil
}

// ValidateEgressName checks a destination name: a lowercase hostname with no
// wildcard, port or path. It is the service name of egress://<name>, so it
// must also be valid there.
func ValidateEgressName(name string) error {
if name == "" {
return fmt.Errorf("egress entry has no name")
}
if name != NormalizeMeshHost(name) {
return fmt.Errorf("egress name %q must be lowercase with no trailing dot", name)
}
if strings.ContainsAny(name, "*:/") {
return fmt.Errorf("egress name %q must be one hostname: no wildcard, port or path", name)
}
if err := ValidateServiceFormat(EgressServicePrefix + name); err != nil {
return err
}
return nil
}

// ValidateEgressServicePattern checks an egress entry of allowed_services or
// http.service. The generic service validator accepts a path and any case,
// which for the other types name a service that may exist; an egress name is
// a hostname, matched against a lowercase destination name, so a path, a
// port, uppercase or a trailing dot would compile to a grant that matches
// nothing. Entries of other types pass through unchanged.
func ValidateEgressServicePattern(svc string) error {
svcType, name := ParseServiceTarget(svc)
if svcType != ServiceTypeStringEgress {
return nil
}
if err := ValidateServiceFormat(svc); err != nil {
return err
}
if name == "*" {
return nil
}
if strings.ContainsAny(name, ":/") {
return fmt.Errorf("invalid egress pattern %q: a destination is a hostname, without a scheme, a port or a path (write the path in the role's http entry)", svc)
}
if name != NormalizeMeshHost(name) {
return fmt.Errorf("invalid egress pattern %q: destination names are lowercase with no trailing dot", svc)
}
return nil
}

// validateEgressTargetURL accepts an http or https URL with a host and no
// credential; a credential is named by EgressDestination.credential and
// resolved on the serving node.
func validateEgressTargetURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid target_url")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("target_url %q must use http or https", raw)
}
if u.Host == "" {
return fmt.Errorf("target_url %q has no host", raw)
}
if u.User != nil {
return fmt.Errorf("target_url must not carry a credential; name one in credential instead")
}
if u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("target_url %q must not carry a query or a fragment", raw)
}
return nil
}

// EgressTargetURL is where the serving node forwards requests for d:
// target_url when set, otherwise https on the destination name.
func EgressTargetURL(d *EgressDestination) string {
if d.GetTargetUrl() != "" {
return d.GetTargetUrl()
}
return "https://" + d.GetName()
}

// EgressServedBy reports whether a node with these roles and labels is
// selected to serve d.
func EgressServedBy(d *EgressDestination, roles []string, labels map[string]string) bool {
for _, sel := range d.GetServedBy() {
if key, value, isLabel := strings.Cut(sel, "="); isLabel {
if labels[key] == value {
return true
}
continue
}
for _, r := range roles {
if r == sel {
return true
}
}
}
return false
}

// BuildEgressServingRules grants each destination to the nodes that serve it:
// granted_service_exact("egress", name) <- role(r) or <- label(k, v) for every
// served_by entry. The serving node evaluates its own credential when a local
// client asks for the destination, so the grant has to reach it; the rules
// travel with the mesh policy like every other grant.
func BuildEgressServingRules(egress []*EgressDestination) []PolicyRule {
var rules []PolicyRule
for _, d := range egress {
if d == nil || d.GetName() == "" {
continue
}
head := biscuit.Predicate{Name: FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String(ServiceTypeStringEgress), biscuit.String(d.GetName())}}
for _, sel := range d.GetServedBy() {
var body biscuit.Predicate
if key, value, isLabel := strings.Cut(sel, "="); isLabel {
body = biscuit.Predicate{Name: FactLabel, IDs: []biscuit.Term{biscuit.String(key), biscuit.String(value)}}
} else {
body = biscuit.Predicate{Name: FactRole, IDs: []biscuit.Term{biscuit.String(sel)}}
}
r := biscuit.Rule{Head: head, Body: []biscuit.Predicate{body}}
rules = append(rules, PolicyRule{Rule: r, Text: renderRule(r)})
}
}
return rules
}
Loading
Loading