Skip to content

Latest commit

 

History

History
418 lines (326 loc) · 15.1 KB

File metadata and controls

418 lines (326 loc) · 15.1 KB

A core library for policies

Use Cedar policies to define and enforce access control policies in your application.

Policy examples

Allow a user to create a host if the host's name matches a specific pattern, the host's IP is within a certain range, and the host has a specific label:

permit (
   principal == User::"alice",
   action == Action::"create_host",
   resource is Host
) when {
    resource.nameLabels.contains("in_domain") &&
    resource.ip.isInRange(ip("10.0.0.0/24")) &&
    resource.name like "*n*"
};

Observability & Metrics

Treetop includes optional metrics and tracing to help you observe policy evaluation:

Enable in your crate:

[dependencies]
treetop-core = { version = "0", features = ["observability"] }

Register a sink:

use std::sync::Arc;
use treetop_core::metrics::{set_sink, EvaluationStats, ReloadStats, MetricsSink};

struct MySink;
impl MetricsSink for MySink {
    fn on_evaluation(&self, stats: &EvaluationStats) { println!("{:?}", stats); }
    fn on_reload(&self, stats: &ReloadStats) { println!("{:?}", stats); }
}

set_sink(Arc::new(MySink));

A different users have different permissions when it comes to creating hosts. Alice can create hosts within the domain example_domain, irrespective of the IP range, and with any name. Bob on the other hand can only create hosts with a acceptable names for web servers and within a specific IP range, and within the same domain.

permit (
    principal == User::"alice",
    action == Action::"create_host",
    resource is Host
) when {
    resource.nameLabels.contains("example_domain")
};

permit (
    principal == User::"bob",
    action == Action::"create_host",
    resource is Host
) when {
    resource.ip.isInRange(ip("10.0.1.0/24")) &&
    resource.nameLabels.contains("valid_web_name") &&
    resource.nameLabels.contains("example_domain")
};

Alice can perform an action called assign_to_restricted_ips, no matter the resource. Bob can only perform the action assign_to_gateways for hosts within the RFC 1918 range for 10.0.0.0/8. The implementation of these action is up to the client application, but we can imagine that restricted IPs consist of IPs that are critical for infastructure, like gateways, broadcast adresses, and possibly some reserved IPs.

permit (
   principal == User::"alice",
   action == Action::"assign_to_restricted_ips",
   resource
);

permit (
   principal == User::"bob",
   action == Action::"assign_to_gateways",
   resource is Host
) when {
    resource.ip.isInRange(ip("10.0.0.0/8"))
};

Code example

 use regex::Regex;
 use std::sync::Arc;
 use treetop_core::{LabelTarget, Action, AttrValue, DecisionDto, LabelRegistryBuilder, PolicyEngine, Principal, RegexLabeler, Request, Resource, User};

 let policies = r#"
 permit (
    principal == User::"alice",
    action == Action::"create_host",
    resource is Host
 ) when {
     resource.nameLabels.contains("in_domain") &&
     resource.ip.isInRange(ip("10.0.0.0/24")) &&
     resource.name like "*n*"
 };
 "#;

 // Used to create attributes for hosts based on their names.
 let patterns = vec![
     ("in_domain".to_string(), Regex::new(r"example\.com$").unwrap()),
     ("webserver".to_string(), Regex::new(r"^web-\d+").unwrap()),
 ];
 let label_registry = LabelRegistryBuilder::new()
     .add_labeler(Arc::new(RegexLabeler::new(LabelTarget::new("Host", "nameLabels").unwrap(), "name",
         patterns.into_iter().collect(),
     ).unwrap()))
     .build()
     .unwrap();

 let engine = PolicyEngine::new_from_str(&policies).unwrap()
     .with_label_registry(label_registry);

 let request = Request {
    principal: Principal::User(User::new("alice", None, None).unwrap()), // No groups, no namespace
    action: Action::new("create_host", None).unwrap(), // Action is not in a namespace
    resource: Resource::new("Host", "hostname.example.com").unwrap()
     .with_attr("name", AttrValue::String("hostname.example.com".into()))
     .with_attr("ip", AttrValue::ip("10.0.0.1").unwrap())
 };

 let decision = engine.evaluate(&request).unwrap();
 assert!(decision.is_allowed());

 // Access policy version information
 let version = decision.version();
 println!("Policy hash: {}", version.hash);
 println!("Policy loaded at: {}", version.loaded_at);
 println!("Engine generation: {}", version.generation);

 // DecisionDto is suitable for serialization but is not authorization proof.
 let wire_decision = DecisionDto::from(&decision);
 let _json = serde_json::to_string(&wire_decision).unwrap();

 // List alice's candidate policies, assuming no groups and no namespaces
 let candidates = engine.list_policies_for_user("alice", &[], &[]).unwrap();
 // This value is also serializable to JSON
 let json = serde_json::to_string(&candidates).unwrap();

Policy-listing methods return structurally matched permit candidates, not an authorization decision. They do not evaluate Cedar when or unless clauses. Always call PolicyEngine::evaluate for the concrete request before granting access.

Principal IDs, group membership, resource attributes, and request context are authorization inputs. Populate them from authenticated, server-controlled state rather than accepting client assertions directly.

Type-Driven Authorization Boundaries

Treetop validates identities when they enter the typed API. User::new, Group::new, Action::new, and Resource::new reject empty or malformed Cedar identities, and AttrValue::ip rejects invalid IP values. Deserialization uses the same validation path, so wire data cannot create states that ordinary constructors reject.

An evaluated Decision is deliberately engine-issued and cannot be deserialized or safely constructed by callers. Convert it to DecisionDto for storage or transport, but never treat a DTO received from elsewhere as proof of authorization.

Policies, schemas, policy-store layouts, and label registries are published as one immutable engine generation. Capture a session when several evaluations must use exactly the same generation, even if the live engine is reloaded concurrently:

let session = engine.session();
let version = session.version();
let first = session.evaluate(&request).unwrap();
let second = session.evaluate(&request).unwrap();

assert_eq!(first.version(), &version);
assert_eq!(second.version(), &version);

Use LabelRegistryBuilder::versioned("host-labels-v1") when decisions need a stable label-configuration identifier for audit correlation across processes or restarts. LabelRegistryBuilder::new() avoids that naming requirement; the engine generation still distinguishes registry replacements within one engine.

Custom labelers retain receiver-style application. Implementations only derive one declared (resource type, attribute) target from an immutable resource; the blanket LabelerApply implementation owns replace-or-remove mutation, so it cannot be overridden by an individual labeler:

use treetop_core::{LabelTarget, AttrValue, Labeler, LabelerApply, Resource};

struct EnvironmentLabeler(LabelTarget);

impl Labeler for EnvironmentLabeler {
    fn target(&self) -> &LabelTarget {
        &self.0
    }

    fn derive(&self, resource: &Resource) -> Option<AttrValue> {
        resource.id().ends_with(".prod").then(|| AttrValue::String("prod".into()))
    }
}

let mut resource = Resource::new("Host", "api.prod").unwrap();
let labeler = EnvironmentLabeler(LabelTarget::new("Host", "environment").unwrap());
labeler.apply(&mut resource);

Targets use exact, fully qualified Cedar resource types. App::Host.labels and Other::Host.labels can have different owners; two owners of App::Host.labels are rejected while building the registry. Core checks the scope even for direct labeler.apply(...) calls. Wildcards and arbitrary applicability predicates are not supported.

Before any derivation, the registry clears every attribute owned on that resource type. Same-named attributes on unrelated types remain application-owned and are not sanitized. Policies must constrain the resource type before treating an attribute as server-derived. See the breaking migration.

If your Cedar policies use context, pass it explicitly at evaluation time:

use treetop_core::{AttrValue, RequestContext};

let context = RequestContext::new()
    .with_attr("env", AttrValue::String("prod".into()))
    .with_attr("ticket", AttrValue::Long(1234));

let decision = engine.evaluate_with_context(&request, &context).unwrap();

Conceptually, context and entity attributes solve different problems:

  • Use entity attributes (resource.<field>, principal/group attributes) for facts that belong to the entity itself and are part of its modeled state.
  • Use request context (context.<field>) for transient, per-request inputs that do not belong on the entity, such as ticket numbers, environment, or request metadata.
  • A useful rule of thumb: if the value should still be true when you evaluate a different request tomorrow, it is usually an entity attribute; if it only matters for this authorization attempt, it is usually request context.

Cedar Schema Validation

Schema validation is optional and opt-in. PolicyEngine::new_from_str(...) returns PolicyEngine<SchemaFree>, while schema constructors return PolicyEngine<SchemaEnforcing>. A schema-free engine has no schema-replacing reload method, so schema enforcement cannot be enabled accidentally after construction. Normal reload_from_str(...) calls preserve the engine's mode.

When you want schema enforcement:

use treetop_core::PolicyEngine;

let policies = r#"
permit (
    principal == User::"alice",
    action == Action::"read",
    resource is Document
);
"#;

let schema = r#"
entity User;
entity Document;
action "read" appliesTo {
    principal: [User],
    resource: [Document],
};
"#;

let engine = PolicyEngine::new_from_str_with_cedarschema(policies, schema).unwrap();

// Re-uses the same schema already loaded in the engine
engine.reload_from_str(policies).unwrap();

With schema validation enabled:

  • policy load/reload fails if policies do not type-check against the schema
  • request evaluation fails with RequestValidationError when principal/action/resource violates schema appliesTo
  • entity construction fails when attributes do not conform to schema types

You can also replace the schema during reload:

use cedar_policy::Schema;
use treetop_core::PolicyEngine;

let engine = PolicyEngine::new_from_str_with_cedarschema(policies, schema_text).unwrap();

// Replace policies + schema in one atomic reload.
let new_schema: Schema = new_schema_text.parse().unwrap();
engine
    .reload_from_str_with_schema(new_policies, new_schema)
    .unwrap();

// Or parse schema text inside the reload call.
engine
    .reload_from_str_with_cedarschema(new_policies, new_schema_text)
    .unwrap();

Reload logging:

  • reload operations emit a PolicyReload debug event
  • fields include schema_enabled, schema_reloaded, and when relevant schema_previously_enabled

Namespace-Partitioned Policy Stores

Large independent authorization domains can opt into policy stores while the existing constructors remain monolithic. Treetop assigns ordinary policies from their namespaced action, resource, and condition references, and routes each request to exactly one store using the same namespace ownership rules:

use treetop_core::{LabelTarget, PolicyEngine, PolicyStoreConfig, PolicyStoreLayout};

let layout = PolicyStoreLayout::new([
    PolicyStoreConfig::new("dns", "ExampleCo::DNS").unwrap(),
    PolicyStoreConfig::new("www", "ExampleCo::WWW").unwrap(),
])
.unwrap()
.with_global_policy_ids(["organization.suspended"])
.unwrap();

let engine = PolicyEngine::new_from_str_with_policy_stores(policies, layout).unwrap();

Store namespaces must be explicit, unique, and non-overlapping. A typo cannot silently create a store. A policy that identifies one store is installed only in that store. Registered global policies and policies annotated with @treetop_store("*") are installed in every store so Cedar's forbid precedence is preserved. An otherwise unscoped policy can be assigned explicitly:

Entity namespaces outside every declared store, such as shared User and Group types, do not select a store and can be used by policies in every store.

@id("dns.emergency-lock")
@treetop_store("dns")
forbid (principal, action, resource)
when { context.emergencyLockdown };

Evaluation fails with PolicyStoreRoutingError when the request belongs to no store or its action and resource identify different stores. Store selection is therefore an authorization boundary: construct action and resource identities from authenticated, application-controlled state. Reloads through reload_from_str* retain the configured layout and publish a fully validated, fully partitioned replacement atomically.

See Policy-Store Design and Format for the complete assignment, annotation, routing, reload, and downstream integration contract.

Groups

Groups are listed as the principal entity type Group, and to permit access to member of a group, you can use the in operator. If you say principal in Group::"admins", it will match any principal that is a member of the group admins, but if you say principal == Group::"admins", it will only match the group itself, not its members. You will almost always want to use the in operator when dealing with groups...

permit (
   principal in Group::"admins",
   action == Action::"manage_hosts",
   resource is Host
)

This is then queried as follows in a request:

let request = Request {
   principal: Principal::User(User::new("alice", None, None).unwrap()),
   action: Action::new("manage_hosts", None).unwrap(),
   resource: Resource::new("Host", "hostname.example.com").unwrap()
    .with_attr("name", AttrValue::String("hostname.example.com".into()))
    .with_attr("ip", AttrValue::ip("10.0.0.1").unwrap())
};

Note that namespaces for groups are inherited from vector of namespaces passed during creation of the User struct. This implies that you cannot use different namespaces for groups and users in the same query.

Another example

Imagine the following policy:

permit (
   principal == User::"alice",
   action == Action::"build_house",
   resource is House
) when {
    resource.id == "house-1"
};

This can be queried with the following request:

Request {
   principal: Principal::User(User::new("alice", None, None).unwrap()),
   action: Action::new("build_house", None).unwrap(),
   resource: Resource::new("House", "house-1").unwrap()
};

Releasing

See RELEASING.md for the tag-based crates.io release process.