From 80cbc1740cadd3ff5cd69ad3a555c122eb27cc04 Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 10:54:33 +0900 Subject: [PATCH 01/19] feat: import nxe-cedar src/ snapshot and rename for PHP extension Vendor the Cedar evaluator sources from nxe-cedar (cd3d1df5c5642a75b27f40fa502022c864272ed3) under src/cedar/ with these mechanical rewrites applied during the import: - Symbol prefixes: nxe_cedar_* -> php_cedar_*, NXE_CEDAR_* -> PHP_CEDAR_* - File names follow the same rename - NGINX-specific types and helpers are renamed to the php_cedar_* namespace as well (ngx_pool_t -> php_cedar_pool_t, ngx_log_error -> php_cedar_log_error, NGX_OK -> PHP_CEDAR_OK, ...); the actual definitions land in the next commit - u_char -> unsigned char to drop the NGINX-flavored typedef The lexer / parser / expression evaluator logic itself is untouched to avoid introducing porting bugs. Files do not build yet; the compatibility layer that provides the php_cedar_* types and helpers arrives in the next commit. src/cedar/UPSTREAM.md records the snapshot commit, the rewrite checklist, and the re-import policy. --- src/cedar/UPSTREAM.md | 73 ++ src/cedar/php_cedar_eval.c | 1572 ++++++++++++++++++++++++++ src/cedar/php_cedar_eval.h | 255 +++++ src/cedar/php_cedar_expr.c | 1786 ++++++++++++++++++++++++++++++ src/cedar/php_cedar_expr.h | 42 + src/cedar/php_cedar_lexer.c | 654 +++++++++++ src/cedar/php_cedar_lexer.h | 30 + src/cedar/php_cedar_parser.c | 2030 ++++++++++++++++++++++++++++++++++ src/cedar/php_cedar_parser.h | 29 + src/cedar/php_cedar_types.h | 515 +++++++++ src/cedar/php_cedar_util.h | 28 + 11 files changed, 7014 insertions(+) create mode 100644 src/cedar/UPSTREAM.md create mode 100644 src/cedar/php_cedar_eval.c create mode 100644 src/cedar/php_cedar_eval.h create mode 100644 src/cedar/php_cedar_expr.c create mode 100644 src/cedar/php_cedar_expr.h create mode 100644 src/cedar/php_cedar_lexer.c create mode 100644 src/cedar/php_cedar_lexer.h create mode 100644 src/cedar/php_cedar_parser.c create mode 100644 src/cedar/php_cedar_parser.h create mode 100644 src/cedar/php_cedar_types.h create mode 100644 src/cedar/php_cedar_util.h diff --git a/src/cedar/UPSTREAM.md b/src/cedar/UPSTREAM.md new file mode 100644 index 0000000..d1edc62 --- /dev/null +++ b/src/cedar/UPSTREAM.md @@ -0,0 +1,73 @@ +# Upstream provenance of the Cedar evaluator sources + +The files under `src/cedar/` (with the exception of `php_cedar_compat.h` +and `php_cedar_compat.c`, which are written for this extension) are a +physical snapshot of the **nxe-cedar** project, rewritten so that the +symbols and dependencies fit a PHP extension. + +## Snapshot commit + +- Upstream repository: +- Commit SHA: `cd3d1df5c5642a75b27f40fa502022c864272ed3` +- Source path: `src/` +- Snapshot date: 2026-05-27 + +## File mapping + +| upstream | php-ext-cedar | +| --- | --- | +| `src/nxe_cedar_eval.c` | `src/cedar/php_cedar_eval.c` | +| `src/nxe_cedar_eval.h` | `src/cedar/php_cedar_eval.h` | +| `src/nxe_cedar_expr.c` | `src/cedar/php_cedar_expr.c` | +| `src/nxe_cedar_expr.h` | `src/cedar/php_cedar_expr.h` | +| `src/nxe_cedar_lexer.c` | `src/cedar/php_cedar_lexer.c` | +| `src/nxe_cedar_lexer.h` | `src/cedar/php_cedar_lexer.h` | +| `src/nxe_cedar_parser.c` | `src/cedar/php_cedar_parser.c` | +| `src/nxe_cedar_parser.h` | `src/cedar/php_cedar_parser.h` | +| `src/nxe_cedar_types.h` | `src/cedar/php_cedar_types.h` | +| `src/nxe_cedar_util.h` | `src/cedar/php_cedar_util.h` | + +## Mechanical rewrites applied on import + +1. Symbol prefixes + - `nxe_cedar_*` → `php_cedar_*` + - `NXE_CEDAR_*` → `PHP_CEDAR_*` +2. File names + - `nxe_cedar_*` → `php_cedar_*` +3. NGINX type and function dependencies removed (via `php_cedar_compat.h`) + - `ngx_pool_t / ngx_str_t / ngx_array_t / ngx_log_t / ngx_int_t / ngx_uint_t` → `php_cedar_*` equivalents + - `ngx_palloc / ngx_pcalloc / ngx_array_create / ngx_array_push / ngx_log_error` → `php_cedar_*` equivalents + - `NGX_OK / NGX_ERROR / NGX_DECLINED` → `PHP_CEDAR_OK / PHP_CEDAR_ERROR / PHP_CEDAR_DECLINED` +4. Memory management is rebased onto the Zend Memory Manager (`emalloc / efree`) + +The evaluation engine itself (lexer, parser, expression evaluator) is +**not modified** — keeping logic identical to upstream avoids reintroducing +bugs and makes diffing easier when re-syncing. When a logic change is +needed, it should go to upstream first and come back via a fresh import. + +## Sync policy + +- No automatic sync. Submodules / subtree are deliberately avoided. +- Re-import on demand: roughly once or twice a year, or whenever upstream + adds something material to the Cedar surface (new data types, new + evaluator capabilities, etc.). +- Re-import procedure: + 1. Review the upstream diff (`git log .. -- src/`). + 2. Apply the diff to this directory with the symbol rewrites already + in place. + 3. Update the "Snapshot commit" section above. + 4. Run the test suite. + +## Capabilities inherited from upstream (and gaps) + +The features below follow whatever the snapshot supports; gaps listed +here are upstream limitations that this extension does **not** plug: + +- No `datetime` / `duration` types or their methods +- No entity tag operators (`hasTag` / `getTag`) +- No policy templates (`?principal`, `?resource`) +- No schema validation +- No dynamic external entity store resolution + +The README of this extension carries the user-facing version of this +list; this file documents the upstream provenance side. diff --git a/src/cedar/php_cedar_eval.c b/src/cedar/php_cedar_eval.c new file mode 100644 index 0000000..2b63023 --- /dev/null +++ b/src/cedar/php_cedar_eval.c @@ -0,0 +1,1572 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_eval.c - Cedar policy set evaluator + * + * Forbid-priority evaluation model: + * 1. Evaluate all policies + * 2. If any forbid matches -> DENY + * 3. If any permit matches -> ALLOW + * 4. If none match -> DENY (default deny) + */ + +#include "php_cedar_compat.h" +#include "php_cedar_eval.h" + + +/* --- scope matching --- */ + +/* + * Reflexive-transitive entity membership check used by `in` scope + * constraints and the `in` expression operator. `parents` is the + * pre-computed transitive closure supplied through + * php_cedar_eval_ctx_add_*_parent(); the reflexive case (X in X) is + * handled inline without registration. + */ +php_cedar_int_t +php_cedar_entity_in_target(php_cedar_str_t *entity_type, php_cedar_str_t *entity_id, + php_cedar_array_t *parents, + php_cedar_str_t *target_type, php_cedar_str_t *target_id) +{ + php_cedar_entity_ref_t *elts; + php_cedar_uint_t i; + + if (php_cedar_str_eq(entity_type, target_type) + && php_cedar_str_eq(entity_id, target_id)) + { + return 1; + } + + if (parents == NULL) { + return 0; + } + + elts = parents->elts; + for (i = 0; i < parents->nelts; i++) { + if (php_cedar_str_eq(&elts[i].type, target_type) + && php_cedar_str_eq(&elts[i].id, target_id)) + { + return 1; + } + } + + return 0; +} + + +/* + * Resolve the parents array for an entity value by its origin slot. + * The slot is stamped on the value when PHP_CEDAR_NODE_VAR evaluation + * produces the principal / action / resource entity. Returns NULL for + * PHP_CEDAR_ENTITY_SLOT_NONE (literals, attribute lookups, set + * elements) so `in` evaluation falls back to reflexive comparison only, + * which matches Cedar semantics: derived entities have no ancestor + * information attached. + * + * The previous (type, id) lookup collapsed on collisions and silently + * returned principal_parents whenever principal / action / resource + * shared the same identity, flipping `in` decisions. + */ +php_cedar_array_t * +php_cedar_eval_ctx_lookup_parents(php_cedar_eval_ctx_t *ctx, + php_cedar_uint_t slot) +{ + if (ctx == NULL) { + return NULL; + } + + switch (slot) { + case PHP_CEDAR_ENTITY_SLOT_PRINCIPAL: + return ctx->principal_parents; + case PHP_CEDAR_ENTITY_SLOT_ACTION: + return ctx->action_parents; + case PHP_CEDAR_ENTITY_SLOT_RESOURCE: + return ctx->resource_parents; + default: + return NULL; + } +} + + +static php_cedar_int_t +php_cedar_scope_matches(php_cedar_scope_t *scope, + php_cedar_str_t *entity_type, php_cedar_str_t *entity_id, + php_cedar_array_t *parents) +{ + php_cedar_node_t *target, **elts; + php_cedar_uint_t i; + + if (scope->constraint == PHP_CEDAR_SCOPE_NONE) { + return 1; + } + + if (scope->constraint == PHP_CEDAR_SCOPE_IS + || scope->constraint == PHP_CEDAR_SCOPE_IS_IN) + { + if (!php_cedar_str_eq(entity_type, &scope->entity_type)) { + return 0; + } + + if (scope->constraint == PHP_CEDAR_SCOPE_IS) { + return 1; + } + + /* IS_IN: reuse hierarchical match on the entity_ref target */ + target = scope->target; + + if (target == NULL + || target->type != PHP_CEDAR_NODE_ENTITY_REF) + { + return 0; + } + + return php_cedar_entity_in_target(entity_type, entity_id, parents, + &target->u.entity_ref.entity_type, + &target->u.entity_ref.entity_id); + } + + target = scope->target; + if (target == NULL) { + return 0; + } + + if (scope->constraint == PHP_CEDAR_SCOPE_EQ) { + if (target->type != PHP_CEDAR_NODE_ENTITY_REF) { + return 0; + } + return (php_cedar_str_eq(entity_type, + &target->u.entity_ref.entity_type) + && php_cedar_str_eq(entity_id, + &target->u.entity_ref.entity_id)); + } + + /* SCOPE_IN */ + if (target->type == PHP_CEDAR_NODE_ENTITY_REF) { + return php_cedar_entity_in_target(entity_type, entity_id, parents, + &target->u.entity_ref.entity_type, + &target->u.entity_ref.entity_id); + } + + /* set target: entity in [Group::"a", Group::"b"] */ + if (target->type == PHP_CEDAR_NODE_SET) { + if (target->u.set_elts == NULL) { + return 0; + } + + elts = target->u.set_elts->elts; + + for (i = 0; i < target->u.set_elts->nelts; i++) { + if (elts[i]->type == PHP_CEDAR_NODE_ENTITY_REF + && php_cedar_entity_in_target(entity_type, entity_id, + parents, + &elts[i]->u.entity_ref.entity_type, + &elts[i]->u.entity_ref.entity_id)) + { + return 1; + } + } + + return 0; + } + + return 0; +} + + +/* --- condition matching --- */ + +static php_cedar_int_t +php_cedar_condition_matches(php_cedar_condition_t *cond, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, php_cedar_log_t *log) +{ + php_cedar_value_t val; + + val = php_cedar_expr_eval(cond->expr, ctx, pool, log); + + if (val.type == PHP_CEDAR_RVAL_ERROR) { + return 0; + } + + if (val.type != PHP_CEDAR_RVAL_BOOL) { + return 0; + } + + if (cond->is_unless) { + return !val.v.bool_val; + } + + return val.v.bool_val; +} + + +/* --- evaluation context API --- */ + +/* + * Reject duplicate attribute names within a single attrs array. + * Entity attributes (principal / action / resource / context) and + * record fields share the same flat (name, value) representation, so + * both contracts use the same uniqueness check: a second insertion + * with a name that already exists returns PHP_CEDAR_ERROR before any push. + * + * Cedar records are semantically unordered key -> value maps with + * unique keys; the parser already rejects duplicates in record + * literals, and equality / hashing assume that invariant. This + * matches the parser-side contract for the injection API too. + * + * Returns 1 if a matching name is already present (caller should + * reject), 0 otherwise. Tolerates a NULL attrs (treated as empty). + */ +static php_cedar_int_t +php_cedar_attrs_has_name(php_cedar_array_t *attrs, php_cedar_str_t *name) +{ + php_cedar_attr_t *elts; + php_cedar_uint_t i; + + if (attrs == NULL || name == NULL) { + return 0; + } + + elts = attrs->elts; + for (i = 0; i < attrs->nelts; i++) { + if (php_cedar_str_eq(&elts[i].name, name)) { + return 1; + } + } + + return 0; +} + + +static php_cedar_int_t +php_cedar_eval_ctx_add_str_attr(php_cedar_array_t *attrs, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + php_cedar_attr_t *attr; + + if (php_cedar_attrs_has_name(attrs, name)) { + return PHP_CEDAR_ERROR; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return PHP_CEDAR_ERROR; + } + + attr->name = *name; + attr->value.type = PHP_CEDAR_RVAL_STRING; + attr->value.v.str_val = *value; + + return PHP_CEDAR_OK; +} + + +static php_cedar_int_t +php_cedar_eval_ctx_add_long_attr(php_cedar_array_t *attrs, + php_cedar_str_t *name, int64_t value) +{ + php_cedar_attr_t *attr; + + if (php_cedar_attrs_has_name(attrs, name)) { + return PHP_CEDAR_ERROR; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return PHP_CEDAR_ERROR; + } + + attr->name = *name; + attr->value.type = PHP_CEDAR_RVAL_LONG; + attr->value.v.long_val = value; + + return PHP_CEDAR_OK; +} + + +static php_cedar_int_t +php_cedar_eval_ctx_add_bool_attr(php_cedar_array_t *attrs, + php_cedar_str_t *name, php_cedar_flag_t value) +{ + php_cedar_attr_t *attr; + + if (php_cedar_attrs_has_name(attrs, name)) { + return PHP_CEDAR_ERROR; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return PHP_CEDAR_ERROR; + } + + attr->name = *name; + attr->value.type = PHP_CEDAR_RVAL_BOOL; + attr->value.v.bool_val = value; + + return PHP_CEDAR_OK; +} + + +/* + * IP attributes are eagerly parsed at injection time so readers can + * see the binary representation directly. Invalid IP strings are + * rejected here with PHP_CEDAR_ERROR instead of surfacing as a silent + * evaluation error on first access. + */ +static php_cedar_int_t +php_cedar_eval_ctx_add_ip_attr(php_cedar_array_t *attrs, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + php_cedar_attr_t *attr; + php_cedar_value_t ip_val; + + if (php_cedar_attrs_has_name(attrs, name)) { + return PHP_CEDAR_ERROR; + } + + ip_val = php_cedar_make_ip(value); + if (ip_val.type == PHP_CEDAR_RVAL_ERROR) { + return PHP_CEDAR_ERROR; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return PHP_CEDAR_ERROR; + } + + attr->name = *name; + attr->value = ip_val; + + return PHP_CEDAR_OK; +} + + +/* + * Decimal attributes are eagerly parsed at injection time, mirroring + * the IP path: callers see malformed input rejected with PHP_CEDAR_ERROR up + * front instead of as a silent evaluation error later. + */ +static php_cedar_int_t +php_cedar_eval_ctx_add_decimal_attr(php_cedar_array_t *attrs, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + php_cedar_attr_t *attr; + php_cedar_value_t dec_val; + + if (php_cedar_attrs_has_name(attrs, name)) { + return PHP_CEDAR_ERROR; + } + + dec_val = php_cedar_make_decimal(value); + if (dec_val.type == PHP_CEDAR_RVAL_ERROR) { + return PHP_CEDAR_ERROR; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return PHP_CEDAR_ERROR; + } + + attr->name = *name; + attr->value = dec_val; + + return PHP_CEDAR_OK; +} + + +/* + * Record handle. + * + * - attrs: array of php_cedar_attr_t (shared with the attribute value + * stored in the owning entity / parent record). + * - pool: owns all record / attribute allocations; freed with the + * evaluation context. + * - depth: current nesting depth (1 = direct child of an entity / + * context, increments by 1 for each php_cedar_record_add_record). + */ +struct php_cedar_record_s { + php_cedar_array_t *attrs; + php_cedar_pool_t *pool; + php_cedar_uint_t depth; +}; + + +static php_cedar_record_t * +php_cedar_record_create(php_cedar_pool_t *pool, php_cedar_uint_t depth) +{ + php_cedar_record_t *rec; + + rec = php_cedar_pcalloc(pool, sizeof(php_cedar_record_t)); + if (rec == NULL) { + return NULL; + } + + rec->attrs = php_cedar_array_create(pool, 4, sizeof(php_cedar_attr_t)); + if (rec->attrs == NULL) { + return NULL; + } + + rec->pool = pool; + rec->depth = depth; + + return rec; +} + + +/* + * Reserve a new record-valued attribute on the given attr array and + * return a populated handle. Shared helper for the four + * php_cedar_eval_ctx_add_*_attr_record entry points. + */ +static php_cedar_record_t * +php_cedar_eval_ctx_add_record_attr(php_cedar_array_t *attrs, php_cedar_pool_t *pool, + php_cedar_str_t *name) +{ + php_cedar_attr_t *attr; + php_cedar_record_t *rec; + + if (php_cedar_attrs_has_name(attrs, name)) { + return NULL; + } + + rec = php_cedar_record_create(pool, 1); + if (rec == NULL) { + return NULL; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return NULL; + } + + attr->name = *name; + attr->value.type = PHP_CEDAR_RVAL_RECORD; + attr->value.v.record_attrs = rec->attrs; + + return rec; +} + + +php_cedar_eval_ctx_t * +php_cedar_eval_ctx_create(php_cedar_pool_t *pool) +{ + php_cedar_eval_ctx_t *ctx; + + if (pool == NULL) { + return NULL; + } + + ctx = php_cedar_pcalloc(pool, sizeof(php_cedar_eval_ctx_t)); + if (ctx == NULL) { + return NULL; + } + + ctx->pool = pool; + + ctx->principal_attrs = php_cedar_array_create(pool, 4, + sizeof(php_cedar_attr_t)); + ctx->action_attrs = php_cedar_array_create(pool, 4, + sizeof(php_cedar_attr_t)); + ctx->resource_attrs = php_cedar_array_create(pool, 4, + sizeof(php_cedar_attr_t)); + ctx->context_attrs = php_cedar_array_create(pool, 4, + sizeof(php_cedar_attr_t)); + + ctx->principal_parents = php_cedar_array_create(pool, 2, + sizeof(php_cedar_entity_ref_t)); + ctx->action_parents = php_cedar_array_create(pool, 2, + sizeof(php_cedar_entity_ref_t)); + ctx->resource_parents = php_cedar_array_create(pool, 2, + sizeof(php_cedar_entity_ref_t)); + + if (ctx->principal_attrs == NULL + || ctx->action_attrs == NULL + || ctx->resource_attrs == NULL + || ctx->context_attrs == NULL + || ctx->principal_parents == NULL + || ctx->action_parents == NULL + || ctx->resource_parents == NULL) + { + return NULL; + } + + return ctx; +} + + +void +php_cedar_eval_ctx_set_principal(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + ctx->principal_type = *type; + ctx->principal_id = *id; +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_principal_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_str_attr(ctx->principal_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_principal_attr_long(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, int64_t value) +{ + return php_cedar_eval_ctx_add_long_attr(ctx->principal_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_principal_attr_bool(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_flag_t value) +{ + return php_cedar_eval_ctx_add_bool_attr(ctx->principal_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_principal_attr_ip(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_ip_attr(ctx->principal_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_principal_attr_decimal(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_decimal_attr(ctx->principal_attrs, + name, value); +} + + +void +php_cedar_eval_ctx_set_action(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + ctx->action_type = *type; + ctx->action_id = *id; +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_action_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_str_attr(ctx->action_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_action_attr_long(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, int64_t value) +{ + return php_cedar_eval_ctx_add_long_attr(ctx->action_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_action_attr_bool(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_flag_t value) +{ + return php_cedar_eval_ctx_add_bool_attr(ctx->action_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_action_attr_ip(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_ip_attr(ctx->action_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_action_attr_decimal(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_decimal_attr(ctx->action_attrs, + name, value); +} + + +void +php_cedar_eval_ctx_set_resource(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + ctx->resource_type = *type; + ctx->resource_id = *id; +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_resource_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_str_attr(ctx->resource_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_resource_attr_long(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, int64_t value) +{ + return php_cedar_eval_ctx_add_long_attr(ctx->resource_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_resource_attr_bool(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_flag_t value) +{ + return php_cedar_eval_ctx_add_bool_attr(ctx->resource_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_resource_attr_ip(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_ip_attr(ctx->resource_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_resource_attr_decimal(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_decimal_attr(ctx->resource_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_context_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_str_attr(ctx->context_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_context_attr_long(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, int64_t value) +{ + return php_cedar_eval_ctx_add_long_attr(ctx->context_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_context_attr_bool(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_flag_t value) +{ + return php_cedar_eval_ctx_add_bool_attr(ctx->context_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_context_attr_ip(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_ip_attr(ctx->context_attrs, + name, value); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_context_attr_decimal(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value) +{ + return php_cedar_eval_ctx_add_decimal_attr(ctx->context_attrs, + name, value); +} + + +php_cedar_record_t * +php_cedar_eval_ctx_add_principal_attr_record(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + return php_cedar_eval_ctx_add_record_attr(ctx->principal_attrs, + ctx->pool, name); +} + + +php_cedar_record_t * +php_cedar_eval_ctx_add_action_attr_record(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + return php_cedar_eval_ctx_add_record_attr(ctx->action_attrs, + ctx->pool, name); +} + + +php_cedar_record_t * +php_cedar_eval_ctx_add_resource_attr_record(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + return php_cedar_eval_ctx_add_record_attr(ctx->resource_attrs, + ctx->pool, name); +} + + +php_cedar_record_t * +php_cedar_eval_ctx_add_context_attr_record(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + return php_cedar_eval_ctx_add_record_attr(ctx->context_attrs, + ctx->pool, name); +} + + +php_cedar_int_t +php_cedar_record_add_str(php_cedar_record_t *rec, php_cedar_str_t *name, + php_cedar_str_t *value) +{ + if (rec == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_str_attr(rec->attrs, name, value); +} + + +php_cedar_int_t +php_cedar_record_add_long(php_cedar_record_t *rec, php_cedar_str_t *name, + int64_t value) +{ + if (rec == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_long_attr(rec->attrs, name, value); +} + + +php_cedar_int_t +php_cedar_record_add_bool(php_cedar_record_t *rec, php_cedar_str_t *name, + php_cedar_flag_t value) +{ + if (rec == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_bool_attr(rec->attrs, name, value); +} + + +php_cedar_int_t +php_cedar_record_add_ip(php_cedar_record_t *rec, php_cedar_str_t *name, + php_cedar_str_t *value) +{ + if (rec == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_ip_attr(rec->attrs, name, value); +} + + +php_cedar_int_t +php_cedar_record_add_decimal(php_cedar_record_t *rec, php_cedar_str_t *name, + php_cedar_str_t *value) +{ + if (rec == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_decimal_attr(rec->attrs, name, value); +} + + +php_cedar_record_t * +php_cedar_record_add_record(php_cedar_record_t *rec, php_cedar_str_t *name) +{ + php_cedar_attr_t *attr; + php_cedar_record_t *child; + + if (rec == NULL) { + return NULL; + } + + if (php_cedar_attrs_has_name(rec->attrs, name)) { + return NULL; + } + + if (rec->depth >= PHP_CEDAR_MAX_RECORD_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, rec->pool->log, 0, + "php_cedar_record_add_record: " + "record nesting exceeds max depth (%d)", + PHP_CEDAR_MAX_RECORD_DEPTH); + return NULL; + } + + child = php_cedar_record_create(rec->pool, rec->depth + 1); + if (child == NULL) { + return NULL; + } + + attr = php_cedar_array_push(rec->attrs); + if (attr == NULL) { + return NULL; + } + + attr->name = *name; + attr->value.type = PHP_CEDAR_RVAL_RECORD; + attr->value.v.record_attrs = child->attrs; + + return child; +} + + +/* --- set values --- */ + +/* + * Set handle. + * + * - elts: array of php_cedar_value_t shared with the attribute value + * stored in the owning entity / record / set; element pushes are + * visible through both views. + * - pool: owns all set / element allocations; freed with the + * evaluation context. + * - depth: nesting depth for set-in-set (1 = direct child of an + * entity / context / record / set). + */ +struct php_cedar_set_s { + php_cedar_array_t *elts; + php_cedar_pool_t *pool; + php_cedar_uint_t depth; +}; + + +static php_cedar_set_t * +php_cedar_set_create(php_cedar_pool_t *pool, php_cedar_uint_t depth) +{ + php_cedar_set_t *set; + + set = php_cedar_pcalloc(pool, sizeof(php_cedar_set_t)); + if (set == NULL) { + return NULL; + } + + set->elts = php_cedar_array_create(pool, 4, sizeof(php_cedar_value_t)); + if (set->elts == NULL) { + return NULL; + } + + set->pool = pool; + set->depth = depth; + + return set; +} + + +/* + * Reserve a new set-valued attribute on the given attr array and + * return a populated handle. Shared helper for the four + * php_cedar_eval_ctx_add_*_attr_set entry points (depth = 1) and for + * php_cedar_record_add_set, which passes its own depth + 1 so a mixed + * record / set graph respects one PHP_CEDAR_MAX_SET_DEPTH ceiling. + */ +static php_cedar_set_t * +php_cedar_eval_ctx_add_set_attr(php_cedar_array_t *attrs, php_cedar_pool_t *pool, + php_cedar_str_t *name, php_cedar_uint_t depth) +{ + php_cedar_attr_t *attr; + php_cedar_set_t *set; + + if (attrs == NULL || pool == NULL || name == NULL) { + return NULL; + } + + if (php_cedar_attrs_has_name(attrs, name)) { + return NULL; + } + + if (depth > PHP_CEDAR_MAX_SET_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, pool->log, 0, + "php_cedar_eval_ctx_add_set_attr: " + "set nesting exceeds max depth (%d)", + PHP_CEDAR_MAX_SET_DEPTH); + return NULL; + } + + set = php_cedar_set_create(pool, depth); + if (set == NULL) { + return NULL; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return NULL; + } + + php_cedar_memzero(attr, sizeof(php_cedar_attr_t)); + attr->name = *name; + attr->value.type = PHP_CEDAR_RVAL_SET; + attr->value.v.set_elts = set->elts; + + return set; +} + + +/* + * Append an entity-valued attribute to the given attr array. + * Shared helper for the four php_cedar_eval_ctx_add_*_attr_entity + * entry points and for php_cedar_record_add_entity(). + */ +static php_cedar_int_t +php_cedar_eval_ctx_add_entity_attr(php_cedar_array_t *attrs, + php_cedar_str_t *name, php_cedar_str_t *type, php_cedar_str_t *id) +{ + php_cedar_attr_t *attr; + + if (attrs == NULL || name == NULL || type == NULL || id == NULL) { + return PHP_CEDAR_ERROR; + } + + if (php_cedar_attrs_has_name(attrs, name)) { + return PHP_CEDAR_ERROR; + } + + attr = php_cedar_array_push(attrs); + if (attr == NULL) { + return PHP_CEDAR_ERROR; + } + + php_cedar_memzero(attr, sizeof(php_cedar_attr_t)); + attr->name = *name; + attr->value.type = PHP_CEDAR_RVAL_ENTITY; + attr->value.v.entity.type = *type; + attr->value.v.entity.id = *id; + + return PHP_CEDAR_OK; +} + + +php_cedar_int_t +php_cedar_set_add_str(php_cedar_set_t *set, php_cedar_str_t *value) +{ + php_cedar_value_t *v; + + if (set == NULL || value == NULL) { + return PHP_CEDAR_ERROR; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return PHP_CEDAR_ERROR; + } + + php_cedar_memzero(v, sizeof(php_cedar_value_t)); + v->type = PHP_CEDAR_RVAL_STRING; + v->v.str_val = *value; + + return PHP_CEDAR_OK; +} + + +php_cedar_int_t +php_cedar_set_add_long(php_cedar_set_t *set, int64_t value) +{ + php_cedar_value_t *v; + + if (set == NULL) { + return PHP_CEDAR_ERROR; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return PHP_CEDAR_ERROR; + } + + php_cedar_memzero(v, sizeof(php_cedar_value_t)); + v->type = PHP_CEDAR_RVAL_LONG; + v->v.long_val = value; + + return PHP_CEDAR_OK; +} + + +php_cedar_int_t +php_cedar_set_add_bool(php_cedar_set_t *set, php_cedar_flag_t value) +{ + php_cedar_value_t *v; + + if (set == NULL) { + return PHP_CEDAR_ERROR; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return PHP_CEDAR_ERROR; + } + + php_cedar_memzero(v, sizeof(php_cedar_value_t)); + v->type = PHP_CEDAR_RVAL_BOOL; + v->v.bool_val = value; + + return PHP_CEDAR_OK; +} + + +php_cedar_int_t +php_cedar_set_add_ip(php_cedar_set_t *set, php_cedar_str_t *value) +{ + php_cedar_value_t *v, ip_val; + + if (set == NULL || value == NULL) { + return PHP_CEDAR_ERROR; + } + + ip_val = php_cedar_make_ip(value); + if (ip_val.type == PHP_CEDAR_RVAL_ERROR) { + return PHP_CEDAR_ERROR; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return PHP_CEDAR_ERROR; + } + + *v = ip_val; + + return PHP_CEDAR_OK; +} + + +php_cedar_int_t +php_cedar_set_add_decimal(php_cedar_set_t *set, php_cedar_str_t *value) +{ + php_cedar_value_t *v, dec_val; + + if (set == NULL || value == NULL) { + return PHP_CEDAR_ERROR; + } + + dec_val = php_cedar_make_decimal(value); + if (dec_val.type == PHP_CEDAR_RVAL_ERROR) { + return PHP_CEDAR_ERROR; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return PHP_CEDAR_ERROR; + } + + *v = dec_val; + + return PHP_CEDAR_OK; +} + + +php_cedar_int_t +php_cedar_set_add_entity(php_cedar_set_t *set, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + php_cedar_value_t *v; + + if (set == NULL || type == NULL || id == NULL) { + return PHP_CEDAR_ERROR; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return PHP_CEDAR_ERROR; + } + + php_cedar_memzero(v, sizeof(php_cedar_value_t)); + v->type = PHP_CEDAR_RVAL_ENTITY; + v->v.entity.type = *type; + v->v.entity.id = *id; + + return PHP_CEDAR_OK; +} + + +php_cedar_set_t * +php_cedar_set_add_set(php_cedar_set_t *set) +{ + php_cedar_value_t *v; + php_cedar_set_t *child; + + if (set == NULL) { + return NULL; + } + + if (set->depth >= PHP_CEDAR_MAX_SET_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, set->pool->log, 0, + "php_cedar_set_add_set: " + "set nesting exceeds max depth (%d)", + PHP_CEDAR_MAX_SET_DEPTH); + return NULL; + } + + child = php_cedar_set_create(set->pool, set->depth + 1); + if (child == NULL) { + return NULL; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return NULL; + } + + php_cedar_memzero(v, sizeof(php_cedar_value_t)); + v->type = PHP_CEDAR_RVAL_SET; + v->v.set_elts = child->elts; + + return child; +} + + +php_cedar_record_t * +php_cedar_set_add_record(php_cedar_set_t *set) +{ + php_cedar_value_t *v; + php_cedar_record_t *child; + + if (set == NULL) { + return NULL; + } + + /* + * Inherit the set's depth so a mixed graph (record -> set -> + * record -> ...) shares one ceiling. Without this, kind switches + * reset the counter to 1 and `==` could descend deeper than + * PHP_CEDAR_MAX_RECORD_DEPTH on alternating chains. + */ + if (set->depth >= PHP_CEDAR_MAX_RECORD_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, set->pool->log, 0, + "php_cedar_set_add_record: " + "record nesting exceeds max depth (%d)", + PHP_CEDAR_MAX_RECORD_DEPTH); + return NULL; + } + + child = php_cedar_record_create(set->pool, set->depth + 1); + if (child == NULL) { + return NULL; + } + + v = php_cedar_array_push(set->elts); + if (v == NULL) { + return NULL; + } + + php_cedar_memzero(v, sizeof(php_cedar_value_t)); + v->type = PHP_CEDAR_RVAL_RECORD; + v->v.record_attrs = child->attrs; + + return child; +} + + +php_cedar_set_t * +php_cedar_eval_ctx_add_principal_attr_set(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + if (ctx == NULL) { + return NULL; + } + return php_cedar_eval_ctx_add_set_attr(ctx->principal_attrs, + ctx->pool, name, 1); +} + + +php_cedar_set_t * +php_cedar_eval_ctx_add_action_attr_set(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + if (ctx == NULL) { + return NULL; + } + return php_cedar_eval_ctx_add_set_attr(ctx->action_attrs, + ctx->pool, name, 1); +} + + +php_cedar_set_t * +php_cedar_eval_ctx_add_resource_attr_set(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + if (ctx == NULL) { + return NULL; + } + return php_cedar_eval_ctx_add_set_attr(ctx->resource_attrs, + ctx->pool, name, 1); +} + + +php_cedar_set_t * +php_cedar_eval_ctx_add_context_attr_set(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name) +{ + if (ctx == NULL) { + return NULL; + } + return php_cedar_eval_ctx_add_set_attr(ctx->context_attrs, + ctx->pool, name, 1); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_principal_attr_entity(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (ctx == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_entity_attr(ctx->principal_attrs, + name, type, id); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_action_attr_entity(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (ctx == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_entity_attr(ctx->action_attrs, + name, type, id); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_resource_attr_entity(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (ctx == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_entity_attr(ctx->resource_attrs, + name, type, id); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_context_attr_entity(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (ctx == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_entity_attr(ctx->context_attrs, + name, type, id); +} + + +php_cedar_int_t +php_cedar_record_add_entity(php_cedar_record_t *rec, php_cedar_str_t *name, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (rec == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_entity_attr(rec->attrs, name, type, id); +} + + +php_cedar_set_t * +php_cedar_record_add_set(php_cedar_record_t *rec, php_cedar_str_t *name) +{ + if (rec == NULL) { + return NULL; + } + if (rec->depth >= PHP_CEDAR_MAX_SET_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, rec->pool->log, 0, + "php_cedar_record_add_set: " + "set nesting exceeds max depth (%d)", + PHP_CEDAR_MAX_SET_DEPTH); + return NULL; + } + return php_cedar_eval_ctx_add_set_attr(rec->attrs, rec->pool, name, + rec->depth + 1); +} + + +/* --- entity hierarchy --- */ + +static php_cedar_int_t +php_cedar_eval_ctx_add_parent(php_cedar_array_t *parents, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + php_cedar_entity_ref_t *ref; + + if (parents == NULL || type == NULL || id == NULL) { + return PHP_CEDAR_ERROR; + } + + ref = php_cedar_array_push(parents); + if (ref == NULL) { + return PHP_CEDAR_ERROR; + } + + ref->type = *type; + ref->id = *id; + + return PHP_CEDAR_OK; +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_principal_parent(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (ctx == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_parent(ctx->principal_parents, type, id); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_action_parent(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (ctx == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_parent(ctx->action_parents, type, id); +} + + +php_cedar_int_t +php_cedar_eval_ctx_add_resource_parent(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id) +{ + if (ctx == NULL) { + return PHP_CEDAR_ERROR; + } + return php_cedar_eval_ctx_add_parent(ctx->resource_parents, type, id); +} + + +/* --- main evaluation --- */ + +php_cedar_str_t * +php_cedar_policy_get_annotation(php_cedar_policy_t *policy, php_cedar_str_t *key) +{ + php_cedar_annotation_t *elts; + php_cedar_uint_t i; + + if (policy == NULL || key == NULL || policy->annotations == NULL) { + return NULL; + } + + elts = policy->annotations->elts; + for (i = 0; i < policy->annotations->nelts; i++) { + if (php_cedar_str_eq(&elts[i].key, key)) { + return &elts[i].value; + } + } + + return NULL; +} + + +/* + * Test all scope and condition clauses of `policy` against `ctx`. + * Returns 1 when every clause matches (the policy contributes to the + * decision), 0 otherwise. Shared by the detail-collecting evaluator + * so the forbid and permit passes apply identical match semantics. + */ +static php_cedar_int_t +php_cedar_policy_matches(php_cedar_policy_t *policy, + php_cedar_eval_ctx_t *ctx, php_cedar_log_t *log) +{ + php_cedar_condition_t *conds, *c; + php_cedar_uint_t j; + + if (!php_cedar_scope_matches(&policy->principal, + &ctx->principal_type, &ctx->principal_id, + ctx->principal_parents)) + { + return 0; + } + + if (!php_cedar_scope_matches(&policy->action, + &ctx->action_type, &ctx->action_id, + ctx->action_parents)) + { + return 0; + } + + if (!php_cedar_scope_matches(&policy->resource, + &ctx->resource_type, &ctx->resource_id, + ctx->resource_parents)) + { + return 0; + } + + if (policy->conditions != NULL && policy->conditions->nelts > 0) { + conds = policy->conditions->elts; + + for (j = 0; j < policy->conditions->nelts; j++) { + c = &conds[j]; + + if (!php_cedar_condition_matches(c, ctx, ctx->pool, log)) { + return 0; + } + } + } + + return 1; +} + + +/* + * Record a matching policy into `out->policies`, growing the buffer + * geometrically. Returns PHP_CEDAR_OK on success, PHP_CEDAR_ERROR if allocation + * fails. A NULL `out` is treated as success (detail is opt-in). + */ +static php_cedar_int_t +php_cedar_detail_push(php_cedar_decision_detail_t *out, + php_cedar_policy_t *policy, php_cedar_pool_t *pool, + php_cedar_uint_t *cap) +{ + php_cedar_policy_t **buf; + php_cedar_uint_t new_cap; + + if (out == NULL) { + return PHP_CEDAR_OK; + } + + if (out->npolicies >= *cap) { + new_cap = (*cap == 0) ? 4 : (*cap * 2); + buf = php_cedar_palloc(pool, new_cap * sizeof(php_cedar_policy_t *)); + if (buf == NULL) { + return PHP_CEDAR_ERROR; + } + + if (out->npolicies > 0) { + php_cedar_memcpy(buf, out->policies, + out->npolicies * sizeof(php_cedar_policy_t *)); + } + + out->policies = buf; + *cap = new_cap; + } + + out->policies[out->npolicies++] = policy; + return PHP_CEDAR_OK; +} + + +php_cedar_decision_t +php_cedar_eval_detail(php_cedar_policy_set_t *policy_set, + php_cedar_eval_ctx_t *ctx, php_cedar_log_t *log, + php_cedar_decision_detail_t *out) +{ + php_cedar_policy_t *policies, *p; + php_cedar_uint_t i; + php_cedar_uint_t has_forbid, has_permit, cap; + + if (out != NULL) { + out->policies = NULL; + out->npolicies = 0; + out->errored = NULL; + out->nerrored = 0; + } + + if (policy_set == NULL || policy_set->policies == NULL + || ctx == NULL) + { + return PHP_CEDAR_DECISION_DENY; + } + + policies = policy_set->policies->elts; + + /* + * Forbid pass: evaluate every policy so all matching forbids are + * captured into `out`. Cedar's `forbid` priority means a single + * forbid is enough to deny, but the diagnostic API contract is to + * return every contributing forbid, not just the first one. + */ + has_forbid = 0; + cap = 0; + + for (i = 0; i < policy_set->policies->nelts; i++) { + p = &policies[i]; + + if (!p->is_forbid) { + continue; + } + + if (!php_cedar_policy_matches(p, ctx, log)) { + continue; + } + + has_forbid = 1; + + if (out == NULL) { + /* fast path: no need to enumerate the rest */ + return PHP_CEDAR_DECISION_DENY; + } + + if (php_cedar_detail_push(out, p, ctx->pool, &cap) != PHP_CEDAR_OK) { + /* allocation failure still produces a correct decision */ + return PHP_CEDAR_DECISION_DENY; + } + } + + if (has_forbid) { + return PHP_CEDAR_DECISION_DENY; + } + + /* Permit pass: list every matching permit when no forbid fired. */ + has_permit = 0; + cap = 0; + + for (i = 0; i < policy_set->policies->nelts; i++) { + p = &policies[i]; + + if (p->is_forbid) { + continue; + } + + if (!php_cedar_policy_matches(p, ctx, log)) { + continue; + } + + has_permit = 1; + + if (out == NULL) { + /* fast path: caller only wants the decision */ + return PHP_CEDAR_DECISION_ALLOW; + } + + if (php_cedar_detail_push(out, p, ctx->pool, &cap) != PHP_CEDAR_OK) { + return PHP_CEDAR_DECISION_ALLOW; + } + } + + if (has_permit) { + return PHP_CEDAR_DECISION_ALLOW; + } + + return PHP_CEDAR_DECISION_DENY; +} + + +php_cedar_decision_t +php_cedar_eval(php_cedar_policy_set_t *policy_set, + php_cedar_eval_ctx_t *ctx, php_cedar_log_t *log) +{ + return php_cedar_eval_detail(policy_set, ctx, log, NULL); +} diff --git a/src/cedar/php_cedar_eval.h b/src/cedar/php_cedar_eval.h new file mode 100644 index 0000000..7a1bcd6 --- /dev/null +++ b/src/cedar/php_cedar_eval.h @@ -0,0 +1,255 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_eval.h - Cedar policy set evaluator + * + * Forbid-priority evaluation and context manipulation public API. + */ + +#ifndef PHP_CEDAR_EVAL_H +#define PHP_CEDAR_EVAL_H + +#include "php_cedar_types.h" +#include "php_cedar_expr.h" + + +/* + * Opaque handle used to populate a record-valued attribute one field + * at a time. Created by php_cedar_eval_ctx_add_*_attr_record() for a + * top-level record, or by php_cedar_record_add_record() for a nested + * record. The implementation lives in php_cedar_eval.c. + */ +typedef struct php_cedar_record_s php_cedar_record_t; + +/* + * Opaque handle used to populate a set-valued attribute element by + * element. Created by php_cedar_eval_ctx_add_*_attr_set() for a + * top-level set, by php_cedar_record_add_set() inside a record, or by + * php_cedar_set_add_set() inside another set. + */ +typedef struct php_cedar_set_s php_cedar_set_t; + + +php_cedar_decision_t php_cedar_eval(php_cedar_policy_set_t *policy_set, + php_cedar_eval_ctx_t *ctx, php_cedar_log_t *log); + +/* + * Variant of php_cedar_eval() that records the policies responsible + * for the decision into `out`. On DENY because at least one `forbid` + * matched, `out->policies` lists every matching `forbid`; on ALLOW it + * lists every matching `permit`; on default DENY (no policy matched) + * `out->policies` is NULL and `out->npolicies` is 0. The pointer + * array is allocated from `ctx->pool`; each entry points into the + * input policy set, so the caller must not dereference entries past + * the shorter of `ctx->pool` and the policy set's lifetimes. + * + * Detail collection is best-effort: if allocation fails while growing + * the pointer array, the returned decision remains correct but + * `out->policies` may be truncated (it lists a prefix of the matching + * policies rather than every one). + * + * `out` may be NULL; php_cedar_eval() is a thin wrapper that passes + * NULL and is preserved for callers that only need the decision. + */ +php_cedar_decision_t php_cedar_eval_detail( + php_cedar_policy_set_t *policy_set, + php_cedar_eval_ctx_t *ctx, php_cedar_log_t *log, + php_cedar_decision_detail_t *out); + +/* + * Lookup an annotation value by key on a parsed policy. Returns the + * annotation's value (which may be an empty string for valueless + * annotations like `@deprecated`) or NULL when the key is absent. + * The returned php_cedar_str_t is owned by the policy set; the caller must + * not modify or free it. + */ +php_cedar_str_t *php_cedar_policy_get_annotation(php_cedar_policy_t *policy, + php_cedar_str_t *key); + +php_cedar_eval_ctx_t *php_cedar_eval_ctx_create(php_cedar_pool_t *pool); + +void php_cedar_eval_ctx_set_principal(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_principal_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_principal_attr_long( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, int64_t value); +php_cedar_int_t php_cedar_eval_ctx_add_principal_attr_bool( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_flag_t value); +php_cedar_int_t php_cedar_eval_ctx_add_principal_attr_ip( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_principal_attr_decimal( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); + +void php_cedar_eval_ctx_set_action(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_action_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_action_attr_long( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, int64_t value); +php_cedar_int_t php_cedar_eval_ctx_add_action_attr_bool( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_flag_t value); +php_cedar_int_t php_cedar_eval_ctx_add_action_attr_ip( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_action_attr_decimal( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); + +void php_cedar_eval_ctx_set_resource(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_resource_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_resource_attr_long( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, int64_t value); +php_cedar_int_t php_cedar_eval_ctx_add_resource_attr_bool( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_flag_t value); +php_cedar_int_t php_cedar_eval_ctx_add_resource_attr_ip( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_resource_attr_decimal( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); + +php_cedar_int_t php_cedar_eval_ctx_add_context_attr(php_cedar_eval_ctx_t *ctx, + php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_context_attr_long( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, int64_t value); +php_cedar_int_t php_cedar_eval_ctx_add_context_attr_bool( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_flag_t value); +php_cedar_int_t php_cedar_eval_ctx_add_context_attr_ip( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_eval_ctx_add_context_attr_decimal( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, php_cedar_str_t *value); + +/* + * Record-valued attribute constructors. + * + * Each php_cedar_eval_ctx_add_*_attr_record() reserves a new + * record-valued attribute on the corresponding entity / context and + * returns a handle for populating its fields. Callers add fields via + * php_cedar_record_add_{str,long,bool,ip,record}(). + * + * php_cedar_record_add_record() returns NULL when the resulting record + * would exceed PHP_CEDAR_MAX_RECORD_DEPTH. The record nesting limit is + * aligned with the parser's member-chain limit; scalar fields added + * directly to a record at exactly PHP_CEDAR_MAX_RECORD_DEPTH are + * writable but require one more member step and are not reachable from + * policy text. + * + * Returns NULL on allocation failure as well. + */ +php_cedar_record_t *php_cedar_eval_ctx_add_principal_attr_record( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); +php_cedar_record_t *php_cedar_eval_ctx_add_action_attr_record( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); +php_cedar_record_t *php_cedar_eval_ctx_add_resource_attr_record( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); +php_cedar_record_t *php_cedar_eval_ctx_add_context_attr_record( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); + +php_cedar_int_t php_cedar_record_add_str(php_cedar_record_t *rec, + php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_record_add_long(php_cedar_record_t *rec, + php_cedar_str_t *name, int64_t value); +php_cedar_int_t php_cedar_record_add_bool(php_cedar_record_t *rec, + php_cedar_str_t *name, php_cedar_flag_t value); +php_cedar_int_t php_cedar_record_add_ip(php_cedar_record_t *rec, + php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_int_t php_cedar_record_add_decimal(php_cedar_record_t *rec, + php_cedar_str_t *name, php_cedar_str_t *value); +php_cedar_record_t *php_cedar_record_add_record(php_cedar_record_t *rec, + php_cedar_str_t *name); +php_cedar_int_t php_cedar_record_add_entity(php_cedar_record_t *rec, + php_cedar_str_t *name, php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_set_t *php_cedar_record_add_set(php_cedar_record_t *rec, + php_cedar_str_t *name); + + +/* + * Set-valued attribute constructors. Each call reserves a new + * set-valued attribute on the corresponding entity / context and + * returns a handle for appending elements via + * php_cedar_set_add_{str,long,bool,ip,entity,set,record}(). + * + * Returns NULL on allocation failure. + */ +php_cedar_set_t *php_cedar_eval_ctx_add_principal_attr_set( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); +php_cedar_set_t *php_cedar_eval_ctx_add_action_attr_set( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); +php_cedar_set_t *php_cedar_eval_ctx_add_resource_attr_set( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); +php_cedar_set_t *php_cedar_eval_ctx_add_context_attr_set( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name); + +php_cedar_int_t php_cedar_eval_ctx_add_principal_attr_entity( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, + php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_action_attr_entity( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, + php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_resource_attr_entity( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, + php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_context_attr_entity( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *name, + php_cedar_str_t *type, php_cedar_str_t *id); + + +/* + * Set element constructors. Add one element to an existing set + * handle. php_cedar_set_add_set() and php_cedar_set_add_record() + * return a handle for the new nested container; the other variants + * return PHP_CEDAR_OK / PHP_CEDAR_ERROR. + * + * Set handles enforce PHP_CEDAR_MAX_SET_DEPTH for set-in-set nesting + * and PHP_CEDAR_MAX_RECORD_DEPTH for record values placed inside a + * set; exceeding either ceiling returns NULL. + */ +php_cedar_int_t php_cedar_set_add_str(php_cedar_set_t *set, php_cedar_str_t *value); +php_cedar_int_t php_cedar_set_add_long(php_cedar_set_t *set, int64_t value); +php_cedar_int_t php_cedar_set_add_bool(php_cedar_set_t *set, php_cedar_flag_t value); +php_cedar_int_t php_cedar_set_add_ip(php_cedar_set_t *set, php_cedar_str_t *value); +php_cedar_int_t php_cedar_set_add_decimal(php_cedar_set_t *set, + php_cedar_str_t *value); +php_cedar_int_t php_cedar_set_add_entity(php_cedar_set_t *set, + php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_set_t *php_cedar_set_add_set(php_cedar_set_t *set); +php_cedar_record_t *php_cedar_set_add_record(php_cedar_set_t *set); + + +/* + * Entity hierarchy registration. + * + * Each call records one ancestor of the given entity (principal, + * action, or resource) for `in` evaluation. The caller is responsible + * for supplying the transitive closure: if `User::"alice"` is a member + * of `Group::"developers"`, which is a member of `Group::"staff"`, + * register both `Group::"developers"` and `Group::"staff"` as + * principal parents. Reflexive membership (`X in X`) is handled by the + * evaluator and does not need to be registered. + * + * Returns PHP_CEDAR_OK on success, PHP_CEDAR_ERROR on allocation failure. + */ +php_cedar_int_t php_cedar_eval_ctx_add_principal_parent( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_action_parent( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *type, php_cedar_str_t *id); +php_cedar_int_t php_cedar_eval_ctx_add_resource_parent( + php_cedar_eval_ctx_t *ctx, php_cedar_str_t *type, php_cedar_str_t *id); + + +/* + * Internal helpers shared with the expression evaluator. Resolve the + * ancestor list by the origin slot stamped on the entity value + * (PHP_CEDAR_ENTITY_SLOT_*); returns NULL for PHP_CEDAR_ENTITY_SLOT_NONE + * so `in` evaluation falls back to reflexive comparison only. The + * second helper performs the reflexive + ancestor membership check used + * by `in` operators. + */ +php_cedar_array_t *php_cedar_eval_ctx_lookup_parents( + php_cedar_eval_ctx_t *ctx, php_cedar_uint_t slot); +php_cedar_int_t php_cedar_entity_in_target( + php_cedar_str_t *entity_type, php_cedar_str_t *entity_id, php_cedar_array_t *parents, + php_cedar_str_t *target_type, php_cedar_str_t *target_id); + + +#endif /* PHP_CEDAR_EVAL_H */ diff --git a/src/cedar/php_cedar_expr.c b/src/cedar/php_cedar_expr.c new file mode 100644 index 0000000..6d3bb88 --- /dev/null +++ b/src/cedar/php_cedar_expr.c @@ -0,0 +1,1786 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_expr.c - Cedar expression evaluator + * + * Recursively evaluates AST nodes. + * Returns ERROR on missing attributes or type mismatch, making the policy + * not applicable. + */ + +#include "php_cedar_compat.h" +#include "php_cedar_expr.h" +#include "php_cedar_eval.h" + + +/* forward declaration: real evaluator body, wrapped by + * php_cedar_expr_eval() to manage ctx->eval_depth. */ +static php_cedar_value_t php_cedar_expr_eval_body(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, php_cedar_log_t *log); + + +/* + * Upper bound on the element count of a single record / set passed to + * php_cedar_value_equals(). The bijective match uses a stack bitmap + * sized for this limit; larger containers are rejected with PHP_CEDAR_ERROR + * rather than risking incorrect equality on a degraded match. The + * parser caps record literals at PHP_CEDAR_MAX_RECORD_ENTRIES (64) and + * set literals at PHP_CEDAR_MAX_SET_ELEMENTS (256), so 1024 covers + * those plus considerable headroom for injection-API growth. + */ +#define PHP_CEDAR_VALUE_EQUALS_MAX_ELTS 1024 +#define PHP_CEDAR_VALUE_EQUALS_BITMAP_WORDS \ + ((PHP_CEDAR_VALUE_EQUALS_MAX_ELTS + 63) / 64) + + +/* value constructors */ + +static php_cedar_value_t +php_cedar_make_error(void) +{ + php_cedar_value_t val; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_ERROR; + return val; +} + + +static php_cedar_value_t +php_cedar_make_bool(php_cedar_flag_t b) +{ + php_cedar_value_t val; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_BOOL; + val.v.bool_val = b; + return val; +} + + +static php_cedar_value_t +php_cedar_make_string(php_cedar_str_t s) +{ + php_cedar_value_t val; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_STRING; + val.v.str_val = s; + return val; +} + + +static php_cedar_value_t +php_cedar_make_long(int64_t n) +{ + php_cedar_value_t val; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_LONG; + val.v.long_val = n; + return val; +} + + +static php_cedar_value_t +php_cedar_make_entity(php_cedar_str_t type, php_cedar_str_t id) +{ + php_cedar_value_t val; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_ENTITY; + val.v.entity.type = type; + val.v.entity.id = id; + return val; +} + + +static php_cedar_value_t +php_cedar_make_record(php_cedar_array_t *attrs) +{ + php_cedar_value_t val; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_RECORD; + val.v.record_attrs = attrs; + return val; +} + + +/* parse bounded decimal: overflow-safe with leading-zero rejection */ +static php_cedar_int_t +php_cedar_parse_bounded_dec(unsigned char **pp, unsigned char *end, php_cedar_uint_t max, + php_cedar_uint_t *out) +{ + unsigned char *p, *start; + php_cedar_uint_t val, digit; + + p = *pp; + start = p; + val = 0; + + if (p >= end || *p < '0' || *p > '9') { + return PHP_CEDAR_ERROR; + } + + while (p < end && *p >= '0' && *p <= '9') { + digit = *p - '0'; + if (val > (max - digit) / 10) { + return PHP_CEDAR_ERROR; + } + val = val * 10 + digit; + p++; + } + + /* reject leading zeros (e.g. "08", "010") */ + if (p - start > 1 && *start == '0') { + return PHP_CEDAR_ERROR; + } + + *out = val; + *pp = p; + + return PHP_CEDAR_OK; +} + + +/* parse CIDR prefix length: digits after '/' with leading-zero rejection */ +static php_cedar_int_t +php_cedar_parse_cidr_prefix(unsigned char **pp, unsigned char *end, + php_cedar_uint_t max_prefix, php_cedar_uint_t *prefix_len) +{ + return php_cedar_parse_bounded_dec(pp, end, max_prefix, prefix_len); +} + + +/* parse IPv4 address: "a.b.c.d" with optional "/prefix" */ +static php_cedar_int_t +php_cedar_parse_ipv4(unsigned char *data, size_t len, + unsigned char *addr, php_cedar_uint_t *prefix_len) +{ + unsigned char *p, *end; + php_cedar_uint_t octet, i; + + p = data; + end = data + len; + + for (i = 0; i < 4; i++) { + + if (php_cedar_parse_bounded_dec(&p, end, 255, &octet) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + + addr[i] = (unsigned char) octet; + + if (i < 3) { + if (p >= end || *p != '.') { + return PHP_CEDAR_ERROR; + } + p++; + } + } + + if (p < end && *p == '/') { + p++; + + if (php_cedar_parse_cidr_prefix(&p, end, 32, prefix_len) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + } else { + *prefix_len = 32; + } + + if (p != end) { + return PHP_CEDAR_ERROR; + } + + return PHP_CEDAR_OK; +} + + +/* parse IPv6 address with optional "/prefix" */ +static php_cedar_int_t +php_cedar_parse_ipv6(unsigned char *data, size_t len, + unsigned char *addr, php_cedar_uint_t *prefix_len) +{ + unsigned char *p, *end, *slash; + php_cedar_uint_t groups[8], n_groups, gap_pos, i, val; + size_t addr_len; + + p = data; + + /* split off /prefix if present */ + slash = memchr(data, '/', len); + + if (slash != NULL) { + addr_len = slash - data; + } else { + addr_len = len; + } + + end = data + addr_len; + php_cedar_memzero(groups, sizeof(groups)); + n_groups = 0; + gap_pos = 8; /* sentinel: no gap */ + + /* handle leading "::" */ + if (addr_len >= 2 && p[0] == ':' && p[1] == ':') { + gap_pos = 0; + p += 2; + + if (p == end) { + /* just "::" */ + goto done_groups; + } + } + + while (p < end) { + php_cedar_uint_t digits; + + if (n_groups >= 8) { + return PHP_CEDAR_ERROR; + } + + val = 0; + digits = 0; + + if (*p < '0' + || (*p > '9' && *p < 'A') + || (*p > 'F' && *p < 'a') + || *p > 'f') + { + return PHP_CEDAR_ERROR; + } + + while (p < end && *p != ':') { + if (++digits > 4) { + return PHP_CEDAR_ERROR; + } + + if (*p >= '0' && *p <= '9') { + val = (val << 4) + (*p - '0'); + } else if (*p >= 'a' && *p <= 'f') { + val = (val << 4) + (*p - 'a' + 10); + } else if (*p >= 'A' && *p <= 'F') { + val = (val << 4) + (*p - 'A' + 10); + } else { + return PHP_CEDAR_ERROR; + } + + p++; + } + + groups[n_groups++] = val; + + if (p < end && *p == ':') { + p++; + + if (p < end && *p == ':') { + if (gap_pos != 8) { + return PHP_CEDAR_ERROR; /* double :: */ + } + gap_pos = n_groups; + p++; + + if (p == end) { + break; + } + + } else if (p >= end) { + return PHP_CEDAR_ERROR; /* trailing single colon */ + } + } + } + +done_groups: + + /* expand :: gap into 16-byte addr */ + php_cedar_memzero(addr, 16); + + if (gap_pos == 8) { + /* no gap: must have exactly 8 groups */ + if (n_groups != 8) { + return PHP_CEDAR_ERROR; + } + + for (i = 0; i < 8; i++) { + addr[i * 2] = (unsigned char) (groups[i] >> 8); + addr[i * 2 + 1] = (unsigned char) (groups[i] & 0xFF); + } + + } else { + php_cedar_uint_t tail; + + if (n_groups < gap_pos) { + return PHP_CEDAR_ERROR; + } + + tail = n_groups - gap_pos; + + /* :: must expand to at least one zero group */ + if (gap_pos + tail >= 8) { + return PHP_CEDAR_ERROR; + } + + for (i = 0; i < gap_pos; i++) { + addr[i * 2] = (unsigned char) (groups[i] >> 8); + addr[i * 2 + 1] = (unsigned char) (groups[i] & 0xFF); + } + + for (i = 0; i < tail; i++) { + php_cedar_uint_t pos = 8 - tail + i; + addr[pos * 2] = + (unsigned char) (groups[gap_pos + i] >> 8); + addr[pos * 2 + 1] = + (unsigned char) (groups[gap_pos + i] & 0xFF); + } + } + + /* parse prefix */ + if (slash != NULL) { + p = slash + 1; + end = data + len; + + if (php_cedar_parse_cidr_prefix(&p, end, 128, prefix_len) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + if (p != end) { + return PHP_CEDAR_ERROR; + } + + } else { + *prefix_len = 128; + } + + return PHP_CEDAR_OK; +} + + +/* + * Parse a Cedar decimal literal "[-]?d+\.d{1,4}" into an i64 with an + * implicit scale of 10^4. The Cedar grammar requires at least one + * digit on each side of the decimal point and at most four fractional + * digits; anything else (missing integer part, missing fractional + * digits, trailing garbage, scaled magnitude beyond int64_t) is + * rejected as RVAL_ERROR. Leading zeros are tolerated to match the + * reference parser. + */ +php_cedar_value_t +php_cedar_make_decimal(php_cedar_str_t *s) +{ + php_cedar_value_t val; + unsigned char *p, *end; + php_cedar_flag_t negative; + int64_t int_part, frac_part, scaled; + php_cedar_uint_t frac_digits; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_ERROR; + + if (s == NULL || s->len == 0) { + return val; + } + + p = s->data; + end = p + s->len; + + negative = 0; + if (*p == '-') { + negative = 1; + p++; + if (p == end) { + return val; + } + } + + if (p >= end || *p < '0' || *p > '9') { + return val; + } + + int_part = 0; + while (p < end && *p >= '0' && *p <= '9') { + int64_t d = *p - '0'; + if (int_part > (INT64_MAX - d) / 10) { + return val; + } + int_part = int_part * 10 + d; + p++; + } + + if (p >= end || *p != '.') { + return val; + } + p++; + + frac_part = 0; + frac_digits = 0; + while (p < end && *p >= '0' && *p <= '9') { + if (frac_digits >= 4) { + return val; + } + frac_part = frac_part * 10 + (*p - '0'); + frac_digits++; + p++; + } + + if (frac_digits == 0 || p != end) { + return val; + } + + while (frac_digits < 4) { + frac_part *= 10; + frac_digits++; + } + + /* + * Combine int_part and frac_part into the scaled i64 representation. + * Sign is applied at the multiplication step so that the negative + * range can reach INT64_MIN (-922337203685477.5808): if we negated + * after assembly, the positive intermediate would overflow at + * |INT64_MIN| and we would reject the value, diverging from the + * Cedar reference parser whose range is symmetric only up to + * 922337203685477.5807 / -922337203685477.5808. + */ + if (negative) { + if (__builtin_mul_overflow(int_part, (int64_t) -10000, &scaled)) { + return val; + } + if (__builtin_sub_overflow(scaled, frac_part, &scaled)) { + return val; + } + } else { + if (__builtin_mul_overflow(int_part, (int64_t) 10000, &scaled)) { + return val; + } + if (__builtin_add_overflow(scaled, frac_part, &scaled)) { + return val; + } + } + + val.type = PHP_CEDAR_RVAL_DECIMAL; + val.v.decimal_val = scaled; + return val; +} + + +/* parse IP string to binary runtime value */ +php_cedar_value_t +php_cedar_make_ip(php_cedar_str_t *s) +{ + php_cedar_value_t val; + + /* + * Fast-path reject for obviously-too-long input. The longest valid + * form is "xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/128" (43 chars); + * actual OOB protection lives in parse_ipv4 / parse_ipv6 which + * clamp to data + len. + */ + if (s->len == 0 || s->len > 43) { + return php_cedar_make_error(); + } + + /* + * zero the entire value including addr[4..15] so IPv4 + * (which only writes addr[0..3]) leaves no uninitialised bytes + */ + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_IP; + + /* try IPv4 first (contains dots, no colons) */ + if (memchr(s->data, ':', s->len) == NULL) { + if (php_cedar_parse_ipv4(s->data, s->len, + val.v.ip_addr.addr, + &val.v.ip_addr.prefix_len) + != PHP_CEDAR_OK) + { + return php_cedar_make_error(); + } + + val.v.ip_addr.is_ipv6 = 0; + return val; + } + + /* IPv6 */ + if (php_cedar_parse_ipv6(s->data, s->len, + val.v.ip_addr.addr, + &val.v.ip_addr.prefix_len) + != PHP_CEDAR_OK) + { + return php_cedar_make_error(); + } + + val.v.ip_addr.is_ipv6 = 1; + return val; +} + + +/* + * Strip the principal / action / resource slot tag from an entity + * value before it crosses a composite-expression or container + * boundary (set element, record value, if-then-else result). + * + * The slot is set in PHP_CEDAR_NODE_VAR so `in` can pick the matching + * parents array on (type, id) collisions, but the tag is meaningful + * only while the value is still syntactically the principal / action / + * resource keyword. Once it is buried inside a composite, the derived + * value must be treated as an arbitrary entity and match reflexively + * only in `in` checks. + */ +static void +php_cedar_clear_entity_slot(php_cedar_value_t *val) +{ + if (val->type == PHP_CEDAR_RVAL_ENTITY) { + val->v.entity.slot = PHP_CEDAR_ENTITY_SLOT_NONE; + } +} + + +/* overflow-checked Long arithmetic (Cedar i64::checked_{add,sub,mul}). + * Accepts any result representable in int64_t (including INT64_MIN); + * rejects true overflow. */ +static php_cedar_int_t +php_cedar_long_arith(php_cedar_op_t op, int64_t a, int64_t b, int64_t *out) +{ + switch (op) { + case PHP_CEDAR_OP_PLUS: + return __builtin_add_overflow(a, b, out) ? PHP_CEDAR_ERROR : PHP_CEDAR_OK; + case PHP_CEDAR_OP_MINUS: + return __builtin_sub_overflow(a, b, out) ? PHP_CEDAR_ERROR : PHP_CEDAR_OK; + case PHP_CEDAR_OP_MUL: + return __builtin_mul_overflow(a, b, out) ? PHP_CEDAR_ERROR : PHP_CEDAR_OK; + default: + return PHP_CEDAR_ERROR; + } +} + + +/* + * Tri-state value equality: returns 1 (equal), 0 (not equal), or + * PHP_CEDAR_ERROR when either operand is RVAL_ERROR, when the recursion + * exceeds PHP_CEDAR_MAX_VALUE_EQUALS_DEPTH, or when a set/record bitmap + * cannot accommodate the comparand. Defense in depth: the normal + * evaluation paths reject RVAL_ERROR before storing it in + * record_attrs / set_elts, and the depth ceiling is well above what + * MAX_RECORD_DEPTH / MAX_SET_DEPTH allow injected values to reach. + * Callers must propagate PHP_CEDAR_ERROR as php_cedar_make_error() instead of + * treating it as "not equal". + * + * `depth` is the number of value_equals frames already on the stack; + * top-level callers pass 0, recursive calls pass depth + 1. + */ +static php_cedar_int_t +php_cedar_value_equals(php_cedar_value_t *a, php_cedar_value_t *b, + php_cedar_uint_t depth) +{ + if (depth > PHP_CEDAR_MAX_VALUE_EQUALS_DEPTH) { + return PHP_CEDAR_ERROR; + } + + if (a->type == PHP_CEDAR_RVAL_ERROR + || b->type == PHP_CEDAR_RVAL_ERROR) + { + return PHP_CEDAR_ERROR; + } + + if (a->type != b->type) { + return 0; + } + + switch (a->type) { + + case PHP_CEDAR_RVAL_STRING: + return php_cedar_str_eq(&a->v.str_val, &b->v.str_val); + + case PHP_CEDAR_RVAL_LONG: + return (a->v.long_val == b->v.long_val); + + case PHP_CEDAR_RVAL_BOOL: + return (a->v.bool_val == b->v.bool_val); + + case PHP_CEDAR_RVAL_ENTITY: + return (php_cedar_str_eq(&a->v.entity.type, &b->v.entity.type) + && php_cedar_str_eq(&a->v.entity.id, &b->v.entity.id)); + + case PHP_CEDAR_RVAL_IP: + return (a->v.ip_addr.is_ipv6 == b->v.ip_addr.is_ipv6 + && a->v.ip_addr.prefix_len == b->v.ip_addr.prefix_len + && php_cedar_memcmp(a->v.ip_addr.addr, b->v.ip_addr.addr, + a->v.ip_addr.is_ipv6 ? 16 : 4) == 0); + + case PHP_CEDAR_RVAL_DECIMAL: + return (a->v.decimal_val == b->v.decimal_val); + + case PHP_CEDAR_RVAL_SET: + if (a->v.set_elts == NULL || b->v.set_elts == NULL) { + return 0; + } + if (a->v.set_elts->nelts != b->v.set_elts->nelts) { + return 0; + } + { + php_cedar_value_t *a_elts = a->v.set_elts->elts; + php_cedar_value_t *b_elts = b->v.set_elts->elts; + php_cedar_uint_t i, j; + uint64_t matched[PHP_CEDAR_VALUE_EQUALS_BITMAP_WORDS]; + + if (a->v.set_elts->nelts > PHP_CEDAR_VALUE_EQUALS_MAX_ELTS) { + return PHP_CEDAR_ERROR; + } + + php_cedar_memzero(matched, sizeof(matched)); + + for (i = 0; i < a->v.set_elts->nelts; i++) { + php_cedar_flag_t found = 0; + for (j = 0; j < b->v.set_elts->nelts; j++) { + php_cedar_int_t r; + + if (matched[j >> 6] & ((uint64_t) 1 << (j & 63))) { + continue; + } + + r = php_cedar_value_equals(&a_elts[i], &b_elts[j], + depth + 1); + if (r == PHP_CEDAR_ERROR) { + return PHP_CEDAR_ERROR; + } + if (r) { + matched[j >> 6] |= (uint64_t) 1 << (j & 63); + found = 1; + break; + } + } + if (!found) { + return 0; + } + } + return 1; + } + + case PHP_CEDAR_RVAL_RECORD: + if (a->v.record_attrs == NULL || b->v.record_attrs == NULL) { + return 0; + } + if (a->v.record_attrs->nelts != b->v.record_attrs->nelts) { + return 0; + } + { + php_cedar_attr_t *a_attrs = a->v.record_attrs->elts; + php_cedar_attr_t *b_attrs = b->v.record_attrs->elts; + php_cedar_uint_t i, j; + uint64_t matched[PHP_CEDAR_VALUE_EQUALS_BITMAP_WORDS]; + + if (a->v.record_attrs->nelts + > PHP_CEDAR_VALUE_EQUALS_MAX_ELTS) + { + return PHP_CEDAR_ERROR; + } + + php_cedar_memzero(matched, sizeof(matched)); + + for (i = 0; i < a->v.record_attrs->nelts; i++) { + php_cedar_flag_t found = 0; + for (j = 0; j < b->v.record_attrs->nelts; j++) { + php_cedar_int_t r; + + if (matched[j >> 6] & ((uint64_t) 1 << (j & 63))) { + continue; + } + if (!php_cedar_str_eq(&a_attrs[i].name, + &b_attrs[j].name)) + { + continue; + } + + r = php_cedar_value_equals(&a_attrs[i].value, + &b_attrs[j].value, + depth + 1); + if (r == PHP_CEDAR_ERROR) { + return PHP_CEDAR_ERROR; + } + if (r) { + matched[j >> 6] |= (uint64_t) 1 << (j & 63); + found = 1; + break; + } + /* + * Name matched but values differ. With unique keys + * (parser and injection API both enforce this on a + * and b independently) there is no other b[j] with + * the same name, so the records cannot be equal. + * Short-circuit the entire equality. + */ + return 0; + } + if (!found) { + return 0; + } + } + return 1; + } + + default: + return 0; + } +} + + +/* find attribute by name in attr array */ +static php_cedar_attr_t * +php_cedar_find_attr(php_cedar_array_t *attrs, php_cedar_str_t *name) +{ + php_cedar_attr_t *attr; + php_cedar_uint_t i; + + if (attrs == NULL) { + return NULL; + } + + attr = attrs->elts; + + for (i = 0; i < attrs->nelts; i++) { + if (php_cedar_str_eq(&attr[i].name, name)) { + return &attr[i]; + } + } + + return NULL; +} + + +/* resolve variable type to its attribute array */ +static php_cedar_array_t * +php_cedar_resolve_var_attrs(php_cedar_var_type_t var_type, + php_cedar_eval_ctx_t *ctx) +{ + switch (var_type) { + + case PHP_CEDAR_VAR_PRINCIPAL: + return ctx->principal_attrs; + + case PHP_CEDAR_VAR_ACTION: + return ctx->action_attrs; + + case PHP_CEDAR_VAR_RESOURCE: + return ctx->resource_attrs; + + case PHP_CEDAR_VAR_CONTEXT: + return ctx->context_attrs; + + default: + return NULL; + } +} + + +/* + * Evaluate attribute access expr.attr. + * + * Fast path: object is a VAR (principal/action/resource/context) — look + * up the attribute directly from the corresponding eval_ctx array. + * + * Slow path: object is any other expression (nested ATTR_ACCESS, etc.) — + * evaluate it recursively. If the result is a record, look up the + * attribute from the record's attr array; otherwise return error. + */ +static php_cedar_value_t +php_cedar_eval_attr_access(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, php_cedar_log_t *log) +{ + php_cedar_node_t *object; + php_cedar_array_t *attrs; + php_cedar_attr_t *attr; + php_cedar_value_t obj_val; + + object = node->u.attr_access.object; + + /* fast path: direct variable access */ + if (object->type == PHP_CEDAR_NODE_VAR) { + attrs = php_cedar_resolve_var_attrs(object->u.var_type, ctx); + if (attrs == NULL) { + return php_cedar_make_error(); + } + + attr = php_cedar_find_attr(attrs, &node->u.attr_access.attr); + if (attr == NULL) { + return php_cedar_make_error(); + } + + return attr->value; + } + + /* slow path: evaluate object and descend into record */ + obj_val = php_cedar_expr_eval(object, ctx, pool, log); + if (obj_val.type == PHP_CEDAR_RVAL_ERROR) { + return obj_val; + } + if (obj_val.type != PHP_CEDAR_RVAL_RECORD) { + return php_cedar_make_error(); + } + + attr = php_cedar_find_attr(obj_val.v.record_attrs, + &node->u.attr_access.attr); + if (attr == NULL) { + return php_cedar_make_error(); + } + + return attr->value; +} + + +/* + * Evaluate has expression. + * + * Fast path: object is a VAR — check the corresponding eval_ctx array. + * + * Slow path: object is any other expression — evaluate it. If the + * result is a record, return whether the attribute exists. If the + * object evaluation fails or produces a non-record, `has` is an error + * per Cedar semantics (the expression is not applicable to the object). + */ +static php_cedar_value_t +php_cedar_eval_has(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, php_cedar_log_t *log) +{ + php_cedar_node_t *object; + php_cedar_array_t *attrs; + php_cedar_value_t obj_val; + + object = node->u.has.object; + + if (object->type == PHP_CEDAR_NODE_VAR) { + attrs = php_cedar_resolve_var_attrs(object->u.var_type, ctx); + if (attrs == NULL) { + return php_cedar_make_bool(0); + } + + return php_cedar_make_bool( + php_cedar_find_attr(attrs, &node->u.has.attr) != NULL); + } + + obj_val = php_cedar_expr_eval(object, ctx, pool, log); + if (obj_val.type == PHP_CEDAR_RVAL_ERROR) { + return obj_val; + } + if (obj_val.type != PHP_CEDAR_RVAL_RECORD) { + return php_cedar_make_error(); + } + + return php_cedar_make_bool( + php_cedar_find_attr(obj_val.v.record_attrs, + &node->u.has.attr) != NULL); +} + + +/* + * Wildcard pattern matching for like operator. + * Pattern bytes: 0xFF = wildcard (matches 0+ chars), all others = literal. + * Uses a greedy/backtracking approach. + * + * Invariant: patterns are always produced by + * php_cedar_parser_compile_pattern(), which guarantees that 0xFF bytes + * in the pattern are exclusively wildcard markers. Subject strings may + * contain arbitrary bytes (including 0xFF in non-UTF-8 input), but this + * is safe: pattern-side 0xFF is always consumed first by the wildcard + * branch (line *p == 0xFF), so it never reaches the literal comparison. + */ +static php_cedar_flag_t +php_cedar_like_match(php_cedar_str_t *subject, php_cedar_str_t *pattern) +{ + unsigned char *s, *p, *s_end, *p_end; + unsigned char *star_p, *star_s; + + s = subject->data; + s_end = s + subject->len; + p = pattern->data; + p_end = p + pattern->len; + star_p = NULL; + star_s = NULL; + + while (s < s_end) { + if (p < p_end && *p == 0xFF) { + /* wildcard: record position for backtracking */ + star_p = ++p; + star_s = s; + continue; + } + + if (p < p_end && *p == *s) { + p++; + s++; + continue; + } + + /* mismatch: backtrack to last wildcard */ + if (star_p != NULL) { + p = star_p; + s = ++star_s; + continue; + } + + return 0; + } + + /* consume trailing wildcards in pattern */ + while (p < p_end && *p == 0xFF) { + p++; + } + + return (p == p_end); +} + + +/* CIDR containment: true if obj (host or range) is entirely within range */ +static php_cedar_flag_t +php_cedar_ip_cidr_contains(php_cedar_value_t *obj, + php_cedar_value_t *range) +{ + php_cedar_uint_t addr_len, full_bytes, remaining_bits; + unsigned char mask; + + if (obj->v.ip_addr.is_ipv6 != range->v.ip_addr.is_ipv6) { + return 0; + } + + if (obj->v.ip_addr.prefix_len < range->v.ip_addr.prefix_len) { + return 0; + } + + addr_len = obj->v.ip_addr.is_ipv6 ? 16 : 4; + full_bytes = range->v.ip_addr.prefix_len / 8; + remaining_bits = range->v.ip_addr.prefix_len % 8; + + if (full_bytes > 0 + && php_cedar_memcmp(obj->v.ip_addr.addr, + range->v.ip_addr.addr, full_bytes) != 0) + { + return 0; + } + + if (remaining_bits > 0 && full_bytes < addr_len) { + mask = (unsigned char) (0xFF << (8 - remaining_bits)); + + if ((obj->v.ip_addr.addr[full_bytes] & mask) + != (range->v.ip_addr.addr[full_bytes] & mask)) + { + return 0; + } + } + + return 1; +} + + +/* build well-known IP CIDR range from a fixed prefix byte sequence */ +static php_cedar_value_t +php_cedar_make_ip_range(php_cedar_flag_t is_ipv6, + const unsigned char *prefix_bytes, php_cedar_uint_t prefix_len) +{ + php_cedar_value_t val; + php_cedar_uint_t addr_len; + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_IP; + val.v.ip_addr.is_ipv6 = is_ipv6; + val.v.ip_addr.prefix_len = prefix_len; + addr_len = is_ipv6 ? 16 : 4; + php_cedar_memcpy(val.v.ip_addr.addr, prefix_bytes, addr_len); + return val; +} + + +/* evaluate method call: expr.method(arg) or expr.method() */ +static php_cedar_value_t +php_cedar_eval_method_call(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, + php_cedar_log_t *log) +{ + php_cedar_value_t obj, arg; + php_cedar_str_t *method; + php_cedar_value_t *obj_elts, *arg_elts; + php_cedar_uint_t i, j; + + obj = php_cedar_expr_eval(node->u.method_call.object, ctx, + pool, log); + if (obj.type == PHP_CEDAR_RVAL_ERROR) { + return obj; + } + + method = &node->u.method_call.method; + + /* zero-argument methods */ + if (node->u.method_call.arg == NULL) { + /* isEmpty: receiver must be a Set */ + if (method->len == 7 + && php_cedar_memcmp(method->data, "isEmpty", 7) == 0) + { + if (obj.type != PHP_CEDAR_RVAL_SET) { + return php_cedar_make_error(); + } + + if (obj.v.set_elts == NULL) { + return php_cedar_make_error(); + } + + return php_cedar_make_bool(obj.v.set_elts->nelts == 0); + } + + /* IP inspection methods: receiver must be IP */ + if (obj.type != PHP_CEDAR_RVAL_IP) { + return php_cedar_make_error(); + } + + /* isIpv4 */ + if (method->len == 6 + && php_cedar_memcmp(method->data, "isIpv4", 6) == 0) + { + return php_cedar_make_bool(!obj.v.ip_addr.is_ipv6); + } + + /* isIpv6 */ + if (method->len == 6 + && php_cedar_memcmp(method->data, "isIpv6", 6) == 0) + { + return php_cedar_make_bool(obj.v.ip_addr.is_ipv6); + } + + /* isLoopback: IPv4 127.0.0.0/8, IPv6 ::1/128 */ + if (method->len == 10 + && php_cedar_memcmp(method->data, "isLoopback", 10) == 0) + { + static const unsigned char loopback_v4[4] = { 127, 0, 0, 0 }; + static const unsigned char loopback_v6[16] = { + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1 + }; + php_cedar_value_t range; + + if (obj.v.ip_addr.is_ipv6) { + range = php_cedar_make_ip_range(1, loopback_v6, 128); + + } else { + range = php_cedar_make_ip_range(0, loopback_v4, 8); + } + + return php_cedar_make_bool( + php_cedar_ip_cidr_contains(&obj, &range)); + } + + /* isMulticast: IPv4 224.0.0.0/4, IPv6 ff00::/8 */ + if (method->len == 11 + && php_cedar_memcmp(method->data, "isMulticast", 11) == 0) + { + static const unsigned char multicast_v4[4] = { 224, 0, 0, 0 }; + static const unsigned char multicast_v6[16] = { + 0xff, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 + }; + php_cedar_value_t range; + + if (obj.v.ip_addr.is_ipv6) { + range = php_cedar_make_ip_range(1, multicast_v6, 8); + + } else { + range = php_cedar_make_ip_range(0, multicast_v4, 4); + } + + return php_cedar_make_bool( + php_cedar_ip_cidr_contains(&obj, &range)); + } + + /* unknown zero-arg method */ + return php_cedar_make_error(); + } + + arg = php_cedar_expr_eval(node->u.method_call.arg, ctx, + pool, log); + if (arg.type == PHP_CEDAR_RVAL_ERROR) { + return arg; + } + + /* containsAll */ + if (method->len == 11 + && php_cedar_memcmp(method->data, "containsAll", 11) == 0) + { + if (obj.type != PHP_CEDAR_RVAL_SET + || arg.type != PHP_CEDAR_RVAL_SET) + { + return php_cedar_make_error(); + } + + if (obj.v.set_elts == NULL || arg.v.set_elts == NULL) { + return php_cedar_make_error(); + } + + obj_elts = obj.v.set_elts->elts; + arg_elts = arg.v.set_elts->elts; + + /* every element in arg must exist in obj */ + for (i = 0; i < arg.v.set_elts->nelts; i++) { + php_cedar_flag_t found = 0; + + for (j = 0; j < obj.v.set_elts->nelts; j++) { + php_cedar_int_t r = php_cedar_value_equals(&arg_elts[i], + &obj_elts[j], 0); + if (r == PHP_CEDAR_ERROR) { + return php_cedar_make_error(); + } + if (r) { + found = 1; + break; + } + } + + if (!found) { + return php_cedar_make_bool(0); + } + } + + return php_cedar_make_bool(1); + } + + /* containsAny */ + if (method->len == 11 + && php_cedar_memcmp(method->data, "containsAny", 11) == 0) + { + if (obj.type != PHP_CEDAR_RVAL_SET + || arg.type != PHP_CEDAR_RVAL_SET) + { + return php_cedar_make_error(); + } + + if (obj.v.set_elts == NULL || arg.v.set_elts == NULL) { + return php_cedar_make_error(); + } + + obj_elts = obj.v.set_elts->elts; + arg_elts = arg.v.set_elts->elts; + + /* at least one element in arg must exist in obj */ + for (i = 0; i < arg.v.set_elts->nelts; i++) { + for (j = 0; j < obj.v.set_elts->nelts; j++) { + php_cedar_int_t r = php_cedar_value_equals(&arg_elts[i], + &obj_elts[j], 0); + if (r == PHP_CEDAR_ERROR) { + return php_cedar_make_error(); + } + if (r) { + return php_cedar_make_bool(1); + } + } + } + + return php_cedar_make_bool(0); + } + + /* contains (single element membership) */ + if (method->len == 8 + && php_cedar_memcmp(method->data, "contains", 8) == 0) + { + if (obj.type != PHP_CEDAR_RVAL_SET) { + return php_cedar_make_error(); + } + + if (obj.v.set_elts == NULL) { + return php_cedar_make_error(); + } + + obj_elts = obj.v.set_elts->elts; + + for (i = 0; i < obj.v.set_elts->nelts; i++) { + php_cedar_int_t r = php_cedar_value_equals(&obj_elts[i], &arg, 0); + if (r == PHP_CEDAR_ERROR) { + return php_cedar_make_error(); + } + if (r) { + return php_cedar_make_bool(1); + } + } + + return php_cedar_make_bool(0); + } + + /* isInRange (IP address range membership) */ + if (method->len == 9 + && php_cedar_memcmp(method->data, "isInRange", 9) == 0) + { + if (obj.type != PHP_CEDAR_RVAL_IP + || arg.type != PHP_CEDAR_RVAL_IP) + { + return php_cedar_make_error(); + } + + return php_cedar_make_bool( + php_cedar_ip_cidr_contains(&obj, &arg)); + } + + /* + * Decimal comparison methods. Cedar exposes ordering on decimals + * only via these four methods; the binary <, <=, >, >= operators + * remain reserved for Long. Both receiver and argument must be + * RVAL_DECIMAL, otherwise the method is not applicable and the + * containing policy is treated as a non-match. + */ + if (obj.type == PHP_CEDAR_RVAL_DECIMAL + || arg.type == PHP_CEDAR_RVAL_DECIMAL) + { + if (obj.type != PHP_CEDAR_RVAL_DECIMAL + || arg.type != PHP_CEDAR_RVAL_DECIMAL) + { + return php_cedar_make_error(); + } + + if (method->len == 8 + && php_cedar_memcmp(method->data, "lessThan", 8) == 0) + { + return php_cedar_make_bool( + obj.v.decimal_val < arg.v.decimal_val); + } + + if (method->len == 15 + && php_cedar_memcmp(method->data, "lessThanOrEqual", 15) == 0) + { + return php_cedar_make_bool( + obj.v.decimal_val <= arg.v.decimal_val); + } + + if (method->len == 11 + && php_cedar_memcmp(method->data, "greaterThan", 11) == 0) + { + return php_cedar_make_bool( + obj.v.decimal_val > arg.v.decimal_val); + } + + if (method->len == 18 + && php_cedar_memcmp(method->data, "greaterThanOrEqual", 18) == 0) + { + return php_cedar_make_bool( + obj.v.decimal_val >= arg.v.decimal_val); + } + + return php_cedar_make_error(); + } + + /* unknown method */ + return php_cedar_make_error(); +} + + +/* entity in entity-or-set check, reflexive-transitive over parents */ +static php_cedar_value_t +php_cedar_eval_in(php_cedar_value_t *left, php_cedar_value_t *right, + php_cedar_eval_ctx_t *ctx) +{ + php_cedar_value_t *elts; + php_cedar_array_t *parents; + php_cedar_uint_t i; + + if (left->type != PHP_CEDAR_RVAL_ENTITY) { + return php_cedar_make_error(); + } + + parents = php_cedar_eval_ctx_lookup_parents(ctx, left->v.entity.slot); + + /* entity in entity */ + if (right->type == PHP_CEDAR_RVAL_ENTITY) { + return php_cedar_make_bool(php_cedar_entity_in_target( + &left->v.entity.type, &left->v.entity.id, + parents, + &right->v.entity.type, + &right->v.entity.id)); + } + + /* + * entity in set: every element must be an entity (Cedar requires + * the right-hand side of `in` to be homogeneous), then any single + * entity matching reflexively or via parents wins. Scanning the + * full set before returning keeps the result order-independent — + * `[matching, 1]` and `[1, matching]` both surface the type error. + */ + if (right->type == PHP_CEDAR_RVAL_SET) { + php_cedar_flag_t found; + + if (right->v.set_elts == NULL) { + return php_cedar_make_bool(0); + } + + elts = right->v.set_elts->elts; + found = 0; + + for (i = 0; i < right->v.set_elts->nelts; i++) { + if (elts[i].type != PHP_CEDAR_RVAL_ENTITY) { + return php_cedar_make_error(); + } + if (php_cedar_entity_in_target( + &left->v.entity.type, &left->v.entity.id, parents, + &elts[i].v.entity.type, &elts[i].v.entity.id)) + { + found = 1; + } + } + + return php_cedar_make_bool(found); + } + + return php_cedar_make_error(); +} + + +/* evaluate entity type check: expr is Type [in expr] */ +static php_cedar_value_t +php_cedar_eval_is_check(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, + php_cedar_log_t *log) +{ + php_cedar_value_t left, right; + + left = php_cedar_expr_eval(node->u.is_check.object, ctx, pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + if (left.type != PHP_CEDAR_RVAL_ENTITY) { + return php_cedar_make_error(); + } + + if (!php_cedar_str_eq(&left.v.entity.type, + &node->u.is_check.entity_type)) + { + return php_cedar_make_bool(0); + } + + if (node->u.is_check.in_entity == NULL) { + return php_cedar_make_bool(1); + } + + right = php_cedar_expr_eval(node->u.is_check.in_entity, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + + return php_cedar_eval_in(&left, &right, ctx); +} + + +static php_cedar_value_t +php_cedar_expr_eval_body(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, + php_cedar_log_t *log) +{ + php_cedar_value_t left, right, val; + php_cedar_node_t **node_elts; + php_cedar_value_t *val_slot; + php_cedar_uint_t i; + + if (node == NULL) { + return php_cedar_make_error(); + } + + switch (node->type) { + + case PHP_CEDAR_NODE_BOOL_LIT: + return php_cedar_make_bool(node->u.bool_val); + + case PHP_CEDAR_NODE_STRING_LIT: + return php_cedar_make_string(node->u.string_val); + + case PHP_CEDAR_NODE_LONG_LIT: + return php_cedar_make_long(node->u.long_val); + + case PHP_CEDAR_NODE_IP_LITERAL: + return php_cedar_make_ip(&node->u.ip_literal.addr); + + case PHP_CEDAR_NODE_DECIMAL_LITERAL: + return php_cedar_make_decimal(&node->u.decimal_literal.text); + + case PHP_CEDAR_NODE_ENTITY_REF: + return php_cedar_make_entity(node->u.entity_ref.entity_type, + node->u.entity_ref.entity_id); + + case PHP_CEDAR_NODE_VAR: + switch (node->u.var_type) { + case PHP_CEDAR_VAR_PRINCIPAL: + val = php_cedar_make_entity(ctx->principal_type, + ctx->principal_id); + val.v.entity.slot = PHP_CEDAR_ENTITY_SLOT_PRINCIPAL; + return val; + case PHP_CEDAR_VAR_ACTION: + val = php_cedar_make_entity(ctx->action_type, + ctx->action_id); + val.v.entity.slot = PHP_CEDAR_ENTITY_SLOT_ACTION; + return val; + case PHP_CEDAR_VAR_RESOURCE: + val = php_cedar_make_entity(ctx->resource_type, + ctx->resource_id); + val.v.entity.slot = PHP_CEDAR_ENTITY_SLOT_RESOURCE; + return val; + case PHP_CEDAR_VAR_CONTEXT: + /* context alone is not a value; only context.attr */ + return php_cedar_make_error(); + default: + return php_cedar_make_error(); + } + + case PHP_CEDAR_NODE_ATTR_ACCESS: + return php_cedar_eval_attr_access(node, ctx, pool, log); + + case PHP_CEDAR_NODE_SET: + if (node->u.set_elts == NULL) { + return php_cedar_make_error(); + } + + php_cedar_memzero(&val, sizeof(php_cedar_value_t)); + val.type = PHP_CEDAR_RVAL_SET; + val.v.set_elts = php_cedar_array_create(pool, + node->u.set_elts->nelts, + sizeof(php_cedar_value_t)); + if (val.v.set_elts == NULL) { + return php_cedar_make_error(); + } + + node_elts = node->u.set_elts->elts; + + for (i = 0; i < node->u.set_elts->nelts; i++) { + left = php_cedar_expr_eval(node_elts[i], ctx, pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return php_cedar_make_error(); + } + + val_slot = php_cedar_array_push(val.v.set_elts); + if (val_slot == NULL) { + return php_cedar_make_error(); + } + php_cedar_clear_entity_slot(&left); + *val_slot = left; + } + + return val; + + case PHP_CEDAR_NODE_RECORD: { + php_cedar_record_entry_t *entries; + php_cedar_attr_t *attr_slot; + php_cedar_array_t *attrs; + + if (node->u.record_entries == NULL) { + return php_cedar_make_error(); + } + + /* avoid php_cedar_palloc(pool, 0) for empty record `{}` */ + attrs = php_cedar_array_create(pool, + node->u.record_entries->nelts > 0 + ? node->u.record_entries->nelts : 1, + sizeof(php_cedar_attr_t)); + if (attrs == NULL) { + return php_cedar_make_error(); + } + + entries = node->u.record_entries->elts; + + for (i = 0; i < node->u.record_entries->nelts; i++) { + left = php_cedar_expr_eval(entries[i].value, ctx, pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return php_cedar_make_error(); + } + + attr_slot = php_cedar_array_push(attrs); + if (attr_slot == NULL) { + return php_cedar_make_error(); + } + + php_cedar_clear_entity_slot(&left); + attr_slot->name = entries[i].key; + attr_slot->value = left; + } + + return php_cedar_make_record(attrs); + } + + case PHP_CEDAR_NODE_BINOP: + switch (node->u.binop.op) { + + case PHP_CEDAR_OP_AND: + left = php_cedar_expr_eval(node->u.binop.left, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + if (left.type != PHP_CEDAR_RVAL_BOOL) { + return php_cedar_make_error(); + } + if (!left.v.bool_val) { + return php_cedar_make_bool(0); + } + + right = php_cedar_expr_eval(node->u.binop.right, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + if (right.type != PHP_CEDAR_RVAL_BOOL) { + return php_cedar_make_error(); + } + return right; + + case PHP_CEDAR_OP_OR: + left = php_cedar_expr_eval(node->u.binop.left, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + if (left.type != PHP_CEDAR_RVAL_BOOL) { + return php_cedar_make_error(); + } + if (left.v.bool_val) { + return php_cedar_make_bool(1); + } + + right = php_cedar_expr_eval(node->u.binop.right, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + if (right.type != PHP_CEDAR_RVAL_BOOL) { + return php_cedar_make_error(); + } + return right; + + case PHP_CEDAR_OP_EQ: + left = php_cedar_expr_eval(node->u.binop.left, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + right = php_cedar_expr_eval(node->u.binop.right, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + if (left.type != right.type) { + return php_cedar_make_error(); + } + { + php_cedar_int_t r = php_cedar_value_equals(&left, &right, 0); + if (r == PHP_CEDAR_ERROR) { + return php_cedar_make_error(); + } + return php_cedar_make_bool(r); + } + + case PHP_CEDAR_OP_NE: + left = php_cedar_expr_eval(node->u.binop.left, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + right = php_cedar_expr_eval(node->u.binop.right, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + if (left.type != right.type) { + return php_cedar_make_error(); + } + { + php_cedar_int_t r = php_cedar_value_equals(&left, &right, 0); + if (r == PHP_CEDAR_ERROR) { + return php_cedar_make_error(); + } + return php_cedar_make_bool(!r); + } + + case PHP_CEDAR_OP_IN: + left = php_cedar_expr_eval(node->u.binop.left, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + right = php_cedar_expr_eval(node->u.binop.right, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + return php_cedar_eval_in(&left, &right, ctx); + + case PHP_CEDAR_OP_LT: + case PHP_CEDAR_OP_GT: + case PHP_CEDAR_OP_LE: + case PHP_CEDAR_OP_GE: + left = php_cedar_expr_eval(node->u.binop.left, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + right = php_cedar_expr_eval(node->u.binop.right, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + if (left.type != PHP_CEDAR_RVAL_LONG + || right.type != PHP_CEDAR_RVAL_LONG) + { + return php_cedar_make_error(); + } + + switch (node->u.binop.op) { + case PHP_CEDAR_OP_LT: + return php_cedar_make_bool( + left.v.long_val < right.v.long_val); + case PHP_CEDAR_OP_GT: + return php_cedar_make_bool( + left.v.long_val > right.v.long_val); + case PHP_CEDAR_OP_LE: + return php_cedar_make_bool( + left.v.long_val <= right.v.long_val); + case PHP_CEDAR_OP_GE: + return php_cedar_make_bool( + left.v.long_val >= right.v.long_val); + default: + return php_cedar_make_error(); + } + + case PHP_CEDAR_OP_PLUS: + case PHP_CEDAR_OP_MINUS: + case PHP_CEDAR_OP_MUL: { + int64_t result; + + left = php_cedar_expr_eval(node->u.binop.left, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + right = php_cedar_expr_eval(node->u.binop.right, ctx, + pool, log); + if (right.type == PHP_CEDAR_RVAL_ERROR) { + return right; + } + if (left.type != PHP_CEDAR_RVAL_LONG + || right.type != PHP_CEDAR_RVAL_LONG) + { + return php_cedar_make_error(); + } + if (php_cedar_long_arith(node->u.binop.op, + left.v.long_val, right.v.long_val, + &result) != PHP_CEDAR_OK) + { + return php_cedar_make_error(); + } + return php_cedar_make_long(result); + } + + default: + return php_cedar_make_error(); + } + + case PHP_CEDAR_NODE_UNOP: + left = php_cedar_expr_eval(node->u.unop.operand, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + if (left.type != PHP_CEDAR_RVAL_BOOL) { + return php_cedar_make_error(); + } + return php_cedar_make_bool(!left.v.bool_val); + + case PHP_CEDAR_NODE_NEGATE: + left = php_cedar_expr_eval(node->u.unop.operand, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + if (left.type != PHP_CEDAR_RVAL_LONG) { + return php_cedar_make_error(); + } + /* -INT64_MIN is undefined; reject it */ + if (left.v.long_val == INT64_MIN) { + return php_cedar_make_error(); + } + return php_cedar_make_long(-left.v.long_val); + + /* Phase 2 */ + case PHP_CEDAR_NODE_HAS: + return php_cedar_eval_has(node, ctx, pool, log); + + case PHP_CEDAR_NODE_LIKE: + left = php_cedar_expr_eval(node->u.like.object, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + if (left.type != PHP_CEDAR_RVAL_STRING) { + return php_cedar_make_error(); + } + return php_cedar_make_bool( + php_cedar_like_match(&left.v.str_val, + &node->u.like.pattern)); + + case PHP_CEDAR_NODE_METHOD_CALL: + return php_cedar_eval_method_call(node, ctx, pool, log); + + case PHP_CEDAR_NODE_IS: + return php_cedar_eval_is_check(node, ctx, pool, log); + + case PHP_CEDAR_NODE_IF_THEN_ELSE: + left = php_cedar_expr_eval(node->u.if_then_else.cond, ctx, + pool, log); + if (left.type == PHP_CEDAR_RVAL_ERROR) { + return left; + } + if (left.type != PHP_CEDAR_RVAL_BOOL) { + return php_cedar_make_error(); + } + if (left.v.bool_val) { + val = php_cedar_expr_eval( + node->u.if_then_else.then_expr, ctx, pool, log); + + } else { + val = php_cedar_expr_eval( + node->u.if_then_else.else_expr, ctx, pool, log); + } + php_cedar_clear_entity_slot(&val); + return val; + + default: + return php_cedar_make_error(); + } +} + + +/* + * Public expression evaluator. Manages ctx->eval_depth as a recursion + * guard so deeply nested AST or value walks (record / set values + * injected at runtime) cannot blow the C stack: when ctx->eval_depth + * would exceed PHP_CEDAR_MAX_EVAL_DEPTH the call short-circuits to an + * RVAL_ERROR, which propagates to the policy as deny. + */ +php_cedar_value_t +php_cedar_expr_eval(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, + php_cedar_log_t *log) +{ + php_cedar_value_t val; + + if (ctx == NULL) { + return php_cedar_make_error(); + } + + if (ctx->eval_depth >= PHP_CEDAR_MAX_EVAL_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, log, 0, + "php_cedar_expr_eval: " + "recursion depth exceeded (max %d)", + PHP_CEDAR_MAX_EVAL_DEPTH); + return php_cedar_make_error(); + } + + ctx->eval_depth++; + val = php_cedar_expr_eval_body(node, ctx, pool, log); + ctx->eval_depth--; + + return val; +} diff --git a/src/cedar/php_cedar_expr.h b/src/cedar/php_cedar_expr.h new file mode 100644 index 0000000..bf65fed --- /dev/null +++ b/src/cedar/php_cedar_expr.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_expr.h - Cedar expression evaluator + * + * Recursively evaluates AST nodes and returns runtime values. + * Internal interface between eval.c and expr.c. + */ + +#ifndef PHP_CEDAR_EXPR_H +#define PHP_CEDAR_EXPR_H + +#include "php_cedar_types.h" +#include "php_cedar_util.h" + + +php_cedar_value_t php_cedar_expr_eval(php_cedar_node_t *node, + php_cedar_eval_ctx_t *ctx, php_cedar_pool_t *pool, + php_cedar_log_t *log); + + +/* + * Parse an IP literal string (v4 / v6, with optional CIDR) into a + * runtime value. Returns an RVAL_ERROR value on invalid input. + * Shared with eval.c so the injection API can eagerly materialize + * IP attribute values at insertion time. + */ +php_cedar_value_t php_cedar_make_ip(php_cedar_str_t *s); + + +/* + * Parse a Cedar decimal string ("[-]?d+\.d{1,4}") into a fixed-point + * i64 runtime value with implicit scale 10^4. Returns an RVAL_ERROR + * value when the input is malformed or the scaled magnitude does not + * fit in int64_t. Shared with eval.c so the injection API can eagerly + * materialize decimal attribute values at insertion time. + */ +php_cedar_value_t php_cedar_make_decimal(php_cedar_str_t *s); + + +#endif /* PHP_CEDAR_EXPR_H */ diff --git a/src/cedar/php_cedar_lexer.c b/src/cedar/php_cedar_lexer.c new file mode 100644 index 0000000..3e5c92f --- /dev/null +++ b/src/cedar/php_cedar_lexer.c @@ -0,0 +1,654 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_lexer.c - Cedar policy text tokenizer + * + * Converts input string to a token stream. + * php_cedar_str_t is not NUL-terminated; always check bounds with pos < input.len. + */ + +#include "php_cedar_compat.h" +#include "php_cedar_lexer.h" + + +/* keyword table entry */ +typedef struct { + php_cedar_str_t name; + php_cedar_token_type_t type; +} php_cedar_keyword_t; + + +static php_cedar_keyword_t php_cedar_keywords[] = { + { php_cedar_string("permit"), PHP_CEDAR_TOKEN_PERMIT }, + { php_cedar_string("forbid"), PHP_CEDAR_TOKEN_FORBID }, + { php_cedar_string("when"), PHP_CEDAR_TOKEN_WHEN }, + { php_cedar_string("unless"), PHP_CEDAR_TOKEN_UNLESS }, + { php_cedar_string("principal"), PHP_CEDAR_TOKEN_PRINCIPAL }, + { php_cedar_string("action"), PHP_CEDAR_TOKEN_ACTION }, + { php_cedar_string("resource"), PHP_CEDAR_TOKEN_RESOURCE }, + { php_cedar_string("context"), PHP_CEDAR_TOKEN_CONTEXT }, + { php_cedar_string("true"), PHP_CEDAR_TOKEN_TRUE }, + { php_cedar_string("false"), PHP_CEDAR_TOKEN_FALSE }, + { php_cedar_string("in"), PHP_CEDAR_TOKEN_IN }, + { php_cedar_string("if"), PHP_CEDAR_TOKEN_IF }, + { php_cedar_string("then"), PHP_CEDAR_TOKEN_THEN }, + { php_cedar_string("else"), PHP_CEDAR_TOKEN_ELSE }, + { php_cedar_string("has"), PHP_CEDAR_TOKEN_HAS }, + { php_cedar_string("like"), PHP_CEDAR_TOKEN_LIKE }, + { php_cedar_string("ip"), PHP_CEDAR_TOKEN_IP }, + { php_cedar_string("decimal"), PHP_CEDAR_TOKEN_DECIMAL }, + { php_cedar_string("is"), PHP_CEDAR_TOKEN_IS }, + { php_cedar_null_string, 0 } +}; + + +static void +php_cedar_lexer_skip_whitespace(php_cedar_lexer_t *lexer) +{ + unsigned char ch; + + for ( ;; ) { + if (lexer->pos >= lexer->input.len) { + return; + } + + ch = lexer->input.data[lexer->pos]; + + if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') { + lexer->pos++; + continue; + } + + /* // line comment */ + if (ch == '/' + && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == '/') + { + lexer->pos += 2; + + while (lexer->pos < lexer->input.len + && lexer->input.data[lexer->pos] != '\n') + { + lexer->pos++; + } + + if (lexer->pos < lexer->input.len) { + lexer->pos++; /* skip \n */ + } + + continue; + } + + return; + } +} + + +static php_cedar_int_t +php_cedar_hex_value(unsigned char c) +{ + if (c >= '0' && c <= '9') { + return c - '0'; + } + + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + + return -1; +} + + +static php_cedar_uint_t +php_cedar_utf8_encode(php_cedar_uint_t cp, unsigned char *dst) +{ + if (cp <= 0x7F) { + dst[0] = (unsigned char) cp; + return 1; + } + + if (cp <= 0x7FF) { + dst[0] = (unsigned char) (0xC0 | (cp >> 6)); + dst[1] = (unsigned char) (0x80 | (cp & 0x3F)); + return 2; + } + + if (cp <= 0xFFFF) { + dst[0] = (unsigned char) (0xE0 | (cp >> 12)); + dst[1] = (unsigned char) (0x80 | ((cp >> 6) & 0x3F)); + dst[2] = (unsigned char) (0x80 | (cp & 0x3F)); + return 3; + } + + /* cp <= 0x10FFFF */ + dst[0] = (unsigned char) (0xF0 | (cp >> 18)); + dst[1] = (unsigned char) (0x80 | ((cp >> 12) & 0x3F)); + dst[2] = (unsigned char) (0x80 | ((cp >> 6) & 0x3F)); + dst[3] = (unsigned char) (0x80 | (cp & 0x3F)); + return 4; +} + + +/* + * Decode one escape sequence after the backslash. + * *src points to the character after '\' (e.g. 'n' for \n). + * On success: advances *src past the sequence, writes decoded bytes + * to *dst, and returns PHP_CEDAR_OK. + * On error: returns PHP_CEDAR_ERROR (*src and *dst are undefined). + * reject_xff: if set, reject \xFF (0xFF reserved as wildcard marker). + */ +php_cedar_int_t +php_cedar_decode_escape(unsigned char **src, unsigned char *src_end, + unsigned char **dst, php_cedar_flag_t reject_xff) +{ + unsigned char *p, *d; + + p = *src; + d = *dst; + + if (p >= src_end) { + return PHP_CEDAR_ERROR; + } + + switch (*p) { + case '"': + *d++ = '"'; + p++; + break; + case '\\': + *d++ = '\\'; + p++; + break; + case 'n': + *d++ = '\n'; + p++; + break; + case 'r': + *d++ = '\r'; + p++; + break; + case 't': + *d++ = '\t'; + p++; + break; + case '0': + *d++ = '\0'; + p++; + break; + case 'x': + { + php_cedar_int_t h1, h2; + unsigned char byte; + + if (p + 2 >= src_end) { + return PHP_CEDAR_ERROR; + } + + h1 = php_cedar_hex_value(p[1]); + h2 = php_cedar_hex_value(p[2]); + if (h1 < 0 || h2 < 0) { + return PHP_CEDAR_ERROR; + } + + byte = (unsigned char) (h1 * 16 + h2); + + if (reject_xff && byte == 0xFF) { + return PHP_CEDAR_ERROR; + } + + *d++ = byte; + p += 3; + } + break; + + case 'u': + { + php_cedar_uint_t cp, n_digits, nbytes; + php_cedar_int_t dig; + + if (p + 1 >= src_end || p[1] != '{') { + return PHP_CEDAR_ERROR; + } + + p += 2; /* skip u{ */ + cp = 0; + n_digits = 0; + + while (p < src_end && *p != '}') { + dig = php_cedar_hex_value(*p); + if (dig < 0) { + return PHP_CEDAR_ERROR; + } + + cp = cp * 16 + dig; + n_digits++; + + if (n_digits > 6) { + return PHP_CEDAR_ERROR; + } + + p++; + } + + if (p >= src_end || n_digits == 0) { + return PHP_CEDAR_ERROR; + } + + if (cp > 0x10FFFF + || (cp >= 0xD800 && cp <= 0xDFFF)) + { + return PHP_CEDAR_ERROR; + } + + /* + * UTF-8 encoding never produces byte 0xFF (max leading byte + * is 0xF4), so this cannot inject wildcard markers into + * like patterns even without an explicit reject_xff check. + */ + nbytes = php_cedar_utf8_encode(cp, d); + d += nbytes; + p++; /* skip '}' */ + } + break; + + default: + return PHP_CEDAR_ERROR; + } + + *src = p; + *dst = d; + return PHP_CEDAR_OK; +} + + +static php_cedar_token_t +php_cedar_lexer_read_string(php_cedar_lexer_t *lexer) +{ + php_cedar_token_t token; + size_t start, len; + unsigned char *dst; + php_cedar_uint_t has_escape; + + /* skip opening quote */ + lexer->pos++; + start = lexer->pos; + has_escape = 0; + + while (lexer->pos < lexer->input.len) { + if (lexer->input.data[lexer->pos] == '\\') { + has_escape = 1; + + if (lexer->pos + 1 >= lexer->input.len) { + /* trailing backslash without following char */ + lexer->pos++; + break; + } + + lexer->pos += 2; + continue; + } + + if (lexer->input.data[lexer->pos] == '"') { + break; + } + + lexer->pos++; + } + + if (lexer->pos >= lexer->input.len) { + token.type = PHP_CEDAR_TOKEN_ERROR; + token.value.data = (unsigned char *) "unterminated string"; + token.value.len = 19; + token.raw.data = NULL; + token.raw.len = 0; + token.has_star_escape = 0; + return token; + } + + /* content between quotes */ + len = lexer->pos - start; + lexer->pos++; /* skip closing quote */ + + token.type = PHP_CEDAR_TOKEN_STRING; + token.raw.data = &lexer->input.data[start]; + token.raw.len = len; + token.has_star_escape = 0; + + if (!has_escape) { + token.value.data = &lexer->input.data[start]; + token.value.len = len; + return token; + } + + /* unescape into pool-allocated buffer */ + dst = php_cedar_palloc(lexer->pool, len); + if (dst == NULL) { + token.type = PHP_CEDAR_TOKEN_ERROR; + token.value.data = (unsigned char *) "alloc failed"; + token.value.len = 12; + token.has_star_escape = 0; + return token; + } + + token.value.data = dst; + + { + unsigned char *sp, *sp_end; + + sp = &lexer->input.data[start]; + sp_end = sp + len; + + while (sp < sp_end) { + if (*sp == '\\' && sp + 1 < sp_end) { + sp++; /* skip backslash */ + + /* + * \* is only valid in like pattern strings, but the + * lexer cannot distinguish pattern from regular strings. + * Accept it here so the token is produced; the pattern + * compiler re-processes via token.raw anyway. + */ + if (*sp == '*') { + *dst++ = '*'; + sp++; + token.has_star_escape = 1; + } else if (php_cedar_decode_escape(&sp, sp_end, + &dst, 0) + != PHP_CEDAR_OK) + { + token.type = PHP_CEDAR_TOKEN_ERROR; + token.value.data = + (unsigned char *) "invalid escape sequence"; + token.value.len = 23; + return token; + } + + } else { + *dst++ = *sp++; + } + } + } + + token.value.len = dst - token.value.data; + + return token; +} + + +static php_cedar_token_t +php_cedar_lexer_read_number(php_cedar_lexer_t *lexer) +{ + php_cedar_token_t token; + size_t start; + + start = lexer->pos; + + while (lexer->pos < lexer->input.len + && lexer->input.data[lexer->pos] >= '0' + && lexer->input.data[lexer->pos] <= '9') + { + lexer->pos++; + } + + token.type = PHP_CEDAR_TOKEN_NUMBER; + token.value.data = &lexer->input.data[start]; + token.value.len = lexer->pos - start; + token.raw.data = NULL; + token.raw.len = 0; + token.has_star_escape = 0; + + return token; +} + + +static php_cedar_token_t +php_cedar_lexer_read_ident(php_cedar_lexer_t *lexer) +{ + php_cedar_token_t token; + php_cedar_keyword_t *kw; + size_t start; + php_cedar_uint_t len; + + start = lexer->pos; + + while (lexer->pos < lexer->input.len) { + unsigned char ch = lexer->input.data[lexer->pos]; + + if ((ch >= 'a' && ch <= 'z') + || (ch >= 'A' && ch <= 'Z') + || (ch >= '0' && ch <= '9') + || ch == '_') + { + lexer->pos++; + } else { + break; + } + } + + len = lexer->pos - start; + + /* check keywords */ + for (kw = php_cedar_keywords; kw->name.len != 0; kw++) { + if (kw->name.len == len + && php_cedar_memcmp(kw->name.data, + &lexer->input.data[start], len) == 0) + { + token.type = kw->type; + token.value.data = &lexer->input.data[start]; + token.value.len = len; + token.raw.data = NULL; + token.raw.len = 0; + token.has_star_escape = 0; + return token; + } + } + + token.type = PHP_CEDAR_TOKEN_IDENT; + token.value.data = &lexer->input.data[start]; + token.value.len = len; + token.raw.data = NULL; + token.raw.len = 0; + token.has_star_escape = 0; + + return token; +} + + +void +php_cedar_lexer_init(php_cedar_lexer_t *lexer, + php_cedar_pool_t *pool, php_cedar_log_t *log, const php_cedar_str_t *input) +{ + lexer->input = *input; + lexer->pos = 0; + lexer->pool = pool; + lexer->log = log; +} + + +php_cedar_token_t +php_cedar_lexer_next(php_cedar_lexer_t *lexer) +{ + php_cedar_token_t token; + unsigned char ch; + + token.raw.data = NULL; + token.raw.len = 0; + token.has_star_escape = 0; + + php_cedar_lexer_skip_whitespace(lexer); + + if (lexer->pos >= lexer->input.len) { + token.type = PHP_CEDAR_TOKEN_EOF; + token.value.data = NULL; + token.value.len = 0; + return token; + } + + ch = lexer->input.data[lexer->pos]; + + /* string literal */ + if (ch == '"') { + return php_cedar_lexer_read_string(lexer); + } + + /* number literal */ + if (ch >= '0' && ch <= '9') { + return php_cedar_lexer_read_number(lexer); + } + + /* identifier or keyword */ + if ((ch >= 'a' && ch <= 'z') + || (ch >= 'A' && ch <= 'Z') + || ch == '_') + { + return php_cedar_lexer_read_ident(lexer); + } + + /* two-character operators */ + if (ch == '=' && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == '=') + { + token.type = PHP_CEDAR_TOKEN_EQ; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 2; + lexer->pos += 2; + return token; + } + + if (ch == '!' && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == '=') + { + token.type = PHP_CEDAR_TOKEN_NE; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 2; + lexer->pos += 2; + return token; + } + + if (ch == '&' && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == '&') + { + token.type = PHP_CEDAR_TOKEN_AND; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 2; + lexer->pos += 2; + return token; + } + + if (ch == '|' && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == '|') + { + token.type = PHP_CEDAR_TOKEN_OR; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 2; + lexer->pos += 2; + return token; + } + + if (ch == ':' && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == ':') + { + token.type = PHP_CEDAR_TOKEN_COLONCOLON; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 2; + lexer->pos += 2; + return token; + } + + if (ch == ':') { + token.type = PHP_CEDAR_TOKEN_COLON; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 1; + lexer->pos += 1; + return token; + } + + if (ch == '<' && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == '=') + { + token.type = PHP_CEDAR_TOKEN_LE; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 2; + lexer->pos += 2; + return token; + } + + if (ch == '>' && lexer->pos + 1 < lexer->input.len + && lexer->input.data[lexer->pos + 1] == '=') + { + token.type = PHP_CEDAR_TOKEN_GE; + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 2; + lexer->pos += 2; + return token; + } + + /* single-character tokens */ + token.value.data = &lexer->input.data[lexer->pos]; + token.value.len = 1; + lexer->pos++; + + switch (ch) { + case '!': + token.type = PHP_CEDAR_TOKEN_NOT; + return token; + case '-': + token.type = PHP_CEDAR_TOKEN_MINUS; + return token; + case '+': + token.type = PHP_CEDAR_TOKEN_PLUS; + return token; + case '*': + token.type = PHP_CEDAR_TOKEN_STAR; + return token; + case '.': + token.type = PHP_CEDAR_TOKEN_DOT; + return token; + case ',': + token.type = PHP_CEDAR_TOKEN_COMMA; + return token; + case ';': + token.type = PHP_CEDAR_TOKEN_SEMICOLON; + return token; + case '(': + token.type = PHP_CEDAR_TOKEN_LPAREN; + return token; + case ')': + token.type = PHP_CEDAR_TOKEN_RPAREN; + return token; + case '{': + token.type = PHP_CEDAR_TOKEN_LBRACE; + return token; + case '}': + token.type = PHP_CEDAR_TOKEN_RBRACE; + return token; + case '[': + token.type = PHP_CEDAR_TOKEN_LBRACKET; + return token; + case ']': + token.type = PHP_CEDAR_TOKEN_RBRACKET; + return token; + case '@': + token.type = PHP_CEDAR_TOKEN_AT; + return token; + case '<': + token.type = PHP_CEDAR_TOKEN_LT; + return token; + case '>': + token.type = PHP_CEDAR_TOKEN_GT; + return token; + default: + break; + } + + token.type = PHP_CEDAR_TOKEN_ERROR; + token.value.data = &lexer->input.data[lexer->pos - 1]; + token.value.len = 1; + + return token; +} diff --git a/src/cedar/php_cedar_lexer.h b/src/cedar/php_cedar_lexer.h new file mode 100644 index 0000000..d6db234 --- /dev/null +++ b/src/cedar/php_cedar_lexer.h @@ -0,0 +1,30 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_lexer.h - Cedar policy text tokenizer + */ + +#ifndef PHP_CEDAR_LEXER_H +#define PHP_CEDAR_LEXER_H + +#include "php_cedar_types.h" + + +typedef struct { + php_cedar_str_t input; + size_t pos; + php_cedar_pool_t *pool; + php_cedar_log_t *log; +} php_cedar_lexer_t; + +void php_cedar_lexer_init(php_cedar_lexer_t *lexer, + php_cedar_pool_t *pool, php_cedar_log_t *log, const php_cedar_str_t *input); +php_cedar_token_t php_cedar_lexer_next(php_cedar_lexer_t *lexer); + +/* shared escape decoder (also used by like pattern compiler) */ +php_cedar_int_t php_cedar_decode_escape(unsigned char **src, unsigned char *src_end, + unsigned char **dst, php_cedar_flag_t reject_xff); + + +#endif /* PHP_CEDAR_LEXER_H */ diff --git a/src/cedar/php_cedar_parser.c b/src/cedar/php_cedar_parser.c new file mode 100644 index 0000000..bef5a14 --- /dev/null +++ b/src/cedar/php_cedar_parser.c @@ -0,0 +1,2030 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_parser.c - Cedar policy text recursive descent parser + * + * Converts token stream to AST and builds policy set. + */ + +#include "php_cedar_compat.h" +#include "php_cedar_lexer.h" +#include "php_cedar_parser.h" +#include "php_cedar_util.h" /* php_cedar_str_eq */ + + +#define PHP_CEDAR_MAX_PARSE_DEPTH 64 +#define PHP_CEDAR_MAX_POLICIES 256 +#define PHP_CEDAR_MAX_CONDITIONS 16 +#define PHP_CEDAR_MAX_SET_ELEMENTS 256 +#define PHP_CEDAR_MAX_ANNOTATIONS 16 +#define PHP_CEDAR_MAX_TYPE_PARTS 16 +#define PHP_CEDAR_MAX_RECORD_ENTRIES 64 +/* PHP_CEDAR_MAX_MEMBER_CHAIN is defined in php_cedar_types.h so it can + * be shared with PHP_CEDAR_MAX_RECORD_DEPTH. */ +#define PHP_CEDAR_MAX_BINOP_CHAIN 256 + + +/* parser context (file-local) */ +typedef struct { + php_cedar_lexer_t lexer; + php_cedar_token_t current; + php_cedar_pool_t *pool; + php_cedar_log_t *log; + php_cedar_uint_t depth; + php_cedar_uint_t record_depth; + unsigned error:1; +} php_cedar_parser_ctx_t; + + +/* forward declarations */ +static php_cedar_node_t *php_cedar_parse_expr( + php_cedar_parser_ctx_t *ctx); +static php_cedar_node_t *php_cedar_parse_entity_ref_with_ident( + php_cedar_parser_ctx_t *ctx, php_cedar_str_t first_ident); +static php_cedar_node_t *php_cedar_parse_unary_expr( + php_cedar_parser_ctx_t *ctx); + + +/* + * Check if a token type can be used as an identifier (attribute name). + * Extension function keywords like 'ip' and 'decimal' are valid + * attribute names in Cedar (e.g. context.ip, principal has decimal). + * Add new extension keywords here as they are introduced. + */ +static php_cedar_int_t +php_cedar_token_is_ident(php_cedar_token_type_t type) +{ + return (type == PHP_CEDAR_TOKEN_IDENT + || type == PHP_CEDAR_TOKEN_IP + || type == PHP_CEDAR_TOKEN_DECIMAL); +} + + +static void +php_cedar_parser_advance(php_cedar_parser_ctx_t *ctx) +{ + ctx->current = php_cedar_lexer_next(&ctx->lexer); + + if (ctx->current.type == PHP_CEDAR_TOKEN_ERROR) { + ctx->error = 1; + } +} + + +static php_cedar_int_t +php_cedar_parser_expect(php_cedar_parser_ctx_t *ctx, + php_cedar_token_type_t type) +{ + if (ctx->current.type != type) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected token %d, got %d", type, + ctx->current.type); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + php_cedar_parser_advance(ctx); + return PHP_CEDAR_OK; +} + + +static php_cedar_node_t * +php_cedar_parser_alloc_node(php_cedar_parser_ctx_t *ctx, + php_cedar_node_type_t type) +{ + php_cedar_node_t *node; + + node = php_cedar_pcalloc(ctx->pool, sizeof(php_cedar_node_t)); + if (node == NULL) { + ctx->error = 1; + return NULL; + } + + node->type = type; + return node; +} + + +/* + * Consume a STRING token where like-style wildcards are not allowed + * (bracket access key, `has` operator string branch, record literal + * string key, annotation value, etc.). Rejects non-STRING tokens and + * strings containing the `\*` escape (only valid in `like` patterns). + * On success, copies the string value to *out and advances. + */ +static php_cedar_int_t +php_cedar_parser_consume_attr_name_string(php_cedar_parser_ctx_t *ctx, + php_cedar_str_t *out) +{ + if (ctx->current.type != PHP_CEDAR_TOKEN_STRING) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected string literal"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + if (ctx->current.has_star_escape) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "invalid escape sequence \\*: " + "only valid in like patterns"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + *out = ctx->current.value; + php_cedar_parser_advance(ctx); + return PHP_CEDAR_OK; +} + + +/* + * Compile a like pattern from raw source bytes (between quotes). + * Unescaped '*' becomes 0xFF (wildcard marker). + * '\*' is the only escape that produces a literal '*'. + * Other escapes that decode to '*' (e.g. \x2A, \u{2A}) become + * wildcards, matching Cedar's official semantics. + * Returns PHP_CEDAR_ERROR on invalid escape (sets ctx->error). + */ +static php_cedar_int_t +php_cedar_parser_compile_pattern(php_cedar_parser_ctx_t *ctx, + php_cedar_str_t *raw, php_cedar_str_t *out) +{ + unsigned char *src, *dst, *end; + + dst = php_cedar_palloc(ctx->pool, raw->len); + if (dst == NULL) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + out->data = dst; + src = raw->data; + end = src + raw->len; + + while (src < end) { + /* reject raw 0xFF: reserved as wildcard sentinel */ + if (*src == 0xFF) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + if (*src == '*') { + *dst++ = 0xFF; + src++; + continue; + } + + if (*src == '\\') { + if (src + 1 >= end) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + src++; /* skip backslash */ + + /* \* is the only escape producing literal '*' */ + if (*src == '*') { + *dst++ = '*'; + src++; + continue; + } + + { + unsigned char *dst_before; + + dst_before = dst; + + if (php_cedar_decode_escape(&src, end, &dst, 1) + != PHP_CEDAR_OK) + { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + /* + * If an escape like \x2A or \u{2A} produced '*', + * treat it as a wildcard (Cedar semantics). + */ + if (dst == dst_before + 1 && *dst_before == '*') { + *dst_before = 0xFF; + } + } + + continue; + } + + *dst++ = *src++; + } + + out->len = dst - out->data; + + /* compress consecutive wildcards: "**" → single 0xFF */ + { + unsigned char *r, *w, *oend; + + r = out->data; + w = out->data; + oend = r + out->len; + + while (r < oend) { + *w++ = *r++; + + if (*(r - 1) == 0xFF) { + while (r < oend && *r == 0xFF) { + r++; + } + } + } + + out->len = w - out->data; + } + + return PHP_CEDAR_OK; +} + + +/* + * parse integer from php_cedar_str_t (Cedar i64 domain) + * returns PHP_CEDAR_ERROR on overflow (sets *result to 0) + */ +static php_cedar_int_t +php_cedar_parse_long(php_cedar_str_t *s, int64_t *result) +{ + int64_t val, digit; + size_t i; + + val = 0; + + for (i = 0; i < s->len; i++) { + digit = s->data[i] - '0'; + + /* overflow check: val * 10 + digit > INT64_MAX */ + if (val > (INT64_MAX - digit) / 10) { + *result = 0; + return PHP_CEDAR_ERROR; + } + + val = val * 10 + digit; + } + + *result = val; + return PHP_CEDAR_OK; +} + + +/* + * parse negative integer from php_cedar_str_t (Cedar i64 domain) + * accumulates in negative domain to handle INT64_MIN correctly + * returns PHP_CEDAR_ERROR on underflow (sets *result to 0) + */ +static php_cedar_int_t +php_cedar_parse_neg_long(php_cedar_str_t *s, int64_t *result) +{ + int64_t val, digit; + size_t i; + + val = 0; + + for (i = 0; i < s->len; i++) { + digit = s->data[i] - '0'; + + /* underflow check: val * 10 - digit < INT64_MIN */ + if (val < (INT64_MIN + digit) / 10) { + *result = 0; + return PHP_CEDAR_ERROR; + } + + val = val * 10 - digit; + } + + *result = val; + return PHP_CEDAR_OK; +} + + +/* + * Append "::"IDENT to type_name, advancing the parser past the IDENT. + * The current token must already be IDENT (caller checks). + * Allocates a new buffer from ctx->pool and returns it in *type_name. + */ +static php_cedar_int_t +php_cedar_parser_append_type_segment(php_cedar_parser_ctx_t *ctx, + php_cedar_str_t *type_name, php_cedar_uint_t *parts) +{ + unsigned char *p; + size_t new_len; + + if (++(*parts) > PHP_CEDAR_MAX_TYPE_PARTS) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many type name segments"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + new_len = type_name->len + 2 + ctx->current.value.len; + if (new_len < type_name->len) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + p = php_cedar_palloc(ctx->pool, new_len); + if (p == NULL) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + php_cedar_memcpy(p, type_name->data, type_name->len); + p[type_name->len] = ':'; + p[type_name->len + 1] = ':'; + php_cedar_memcpy(p + type_name->len + 2, + ctx->current.value.data, ctx->current.value.len); + + type_name->data = p; + type_name->len = new_len; + php_cedar_parser_advance(ctx); + + return PHP_CEDAR_OK; +} + + +/* + * parse type name: IDENT { "::" IDENT } + * Consumes the type name tokens and writes the joined name into *out. + * Differs from entity_ref in that there is no trailing "::"id part. + */ +static php_cedar_int_t +php_cedar_parse_type_name(php_cedar_parser_ctx_t *ctx, php_cedar_str_t *out) +{ + php_cedar_str_t type_name; + php_cedar_uint_t parts; + + if (ctx->current.type != PHP_CEDAR_TOKEN_IDENT) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected type name after 'is'"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + type_name = ctx->current.value; + php_cedar_parser_advance(ctx); + parts = 1; + + while (ctx->current.type == PHP_CEDAR_TOKEN_COLONCOLON) { + php_cedar_parser_advance(ctx); + + if (ctx->current.type != PHP_CEDAR_TOKEN_IDENT) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "expected identifier after '::' in type name"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + if (php_cedar_parser_append_type_segment(ctx, &type_name, &parts) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + } + + *out = type_name; + return PHP_CEDAR_OK; +} + + +/* + * parse_entity_ref_with_ident: IDENT already consumed, parse rest + * Format: Type { :: Type } :: "id" + */ +static php_cedar_node_t * +php_cedar_parse_entity_ref_with_ident(php_cedar_parser_ctx_t *ctx, + php_cedar_str_t first_ident) +{ + php_cedar_node_t *node; + php_cedar_str_t type_name; + php_cedar_uint_t parts; + + type_name = first_ident; + parts = 1; + + /* consume :: segments */ + while (ctx->current.type == PHP_CEDAR_TOKEN_COLONCOLON) { + php_cedar_parser_advance(ctx); /* skip :: */ + + if (ctx->current.type == PHP_CEDAR_TOKEN_STRING) { + if (ctx->current.has_star_escape) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "invalid escape sequence \\*: " + "only valid in like patterns"); + ctx->error = 1; + return NULL; + } + + /* Type::"id" - this is the entity id */ + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_ENTITY_REF); + if (node == NULL) { + return NULL; + } + + node->u.entity_ref.entity_type = type_name; + node->u.entity_ref.entity_id = ctx->current.value; + php_cedar_parser_advance(ctx); /* consume string */ + return node; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_IDENT) { + /* Type::SubType - concatenate */ + if (php_cedar_parser_append_type_segment(ctx, &type_name, + &parts) + != PHP_CEDAR_OK) + { + return NULL; + } + continue; + } + + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected string or ident after ::"); + ctx->error = 1; + return NULL; + } + + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected :: after type name"); + ctx->error = 1; + return NULL; +} + + +/* parse set literal: [ expr, ... ] */ +static php_cedar_node_t * +php_cedar_parse_set_literal(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *node, *elem, **slot; + php_cedar_uint_t count; + + php_cedar_parser_advance(ctx); /* skip [ */ + + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_SET); + if (node == NULL) { + return NULL; + } + + node->u.set_elts = php_cedar_array_create(ctx->pool, 4, + sizeof(php_cedar_node_t *)); + if (node->u.set_elts == NULL) { + ctx->error = 1; + return NULL; + } + + count = 0; + + if (ctx->current.type != PHP_CEDAR_TOKEN_RBRACKET) { + elem = php_cedar_parse_expr(ctx); + if (ctx->error) { + return NULL; + } + + slot = php_cedar_array_push(node->u.set_elts); + if (slot == NULL) { + ctx->error = 1; + return NULL; + } + *slot = elem; + count++; + + while (ctx->current.type == PHP_CEDAR_TOKEN_COMMA) { + if (++count > PHP_CEDAR_MAX_SET_ELEMENTS) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many set elements"); + ctx->error = 1; + return NULL; + } + + php_cedar_parser_advance(ctx); + + elem = php_cedar_parse_expr(ctx); + if (ctx->error) { + return NULL; + } + + slot = php_cedar_array_push(node->u.set_elts); + if (slot == NULL) { + ctx->error = 1; + return NULL; + } + *slot = elem; + } + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RBRACKET) + != PHP_CEDAR_OK) + { + return NULL; + } + + return node; +} + + +/* + * Parse a record literal: "{" [ entry { "," entry } ] "}" + * entry := (IDENT | STRING) ":" expr + * + * - Empty record `{}` is allowed. + * - Trailing comma after the last entry IS allowed (matches the Cedar + * reference parser for record literals). + * - Duplicate keys are rejected at parse time. + * - IDENT and STRING keys are stored as-is; equality uses byte match. + * + * Disambiguation from policy-body `when { ... }`: the outer `{` after + * `when` / `unless` is consumed directly by php_cedar_parse_condition + * before the expression parser runs, so any `{` reaching + * php_cedar_parse_primary is a record literal. + */ +static php_cedar_node_t * +php_cedar_parse_record_literal(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *node = NULL; + php_cedar_record_entry_t *entry, *existing; + php_cedar_str_t key; + php_cedar_uint_t count, i; + + /* + * Cap record-literal nesting at PHP_CEDAR_MAX_RECORD_DEPTH so the + * parser cannot construct values deeper than the attribute-injection + * API allows. Matches the member-chain reachability invariant in + * php_cedar_types.h (writable depth == readable depth). + */ + if (++ctx->record_depth > PHP_CEDAR_MAX_RECORD_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "record literal too deeply nested (max %d)", + PHP_CEDAR_MAX_RECORD_DEPTH); + ctx->error = 1; + goto out; + } + + php_cedar_parser_advance(ctx); /* skip { */ + + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_RECORD); + if (node == NULL) { + goto out; + } + + node->u.record_entries = php_cedar_array_create(ctx->pool, 4, + sizeof(php_cedar_record_entry_t)); + if (node->u.record_entries == NULL) { + ctx->error = 1; + node = NULL; + goto out; + } + + /* empty record `{}` */ + if (ctx->current.type == PHP_CEDAR_TOKEN_RBRACE) { + php_cedar_parser_advance(ctx); + goto out; + } + + count = 0; + + for ( ;; ) { + if (++count > PHP_CEDAR_MAX_RECORD_ENTRIES) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many record entries"); + ctx->error = 1; + node = NULL; + goto out; + } + + /* key: IDENT (incl. context-keyword idents) or STRING */ + if (php_cedar_token_is_ident(ctx->current.type)) { + key = ctx->current.value; + php_cedar_parser_advance(ctx); + + } else if (ctx->current.type == PHP_CEDAR_TOKEN_STRING) { + if (php_cedar_parser_consume_attr_name_string(ctx, &key) + != PHP_CEDAR_OK) + { + node = NULL; + goto out; + } + + } else { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "expected identifier or string as record key"); + ctx->error = 1; + node = NULL; + goto out; + } + + /* reject duplicate keys at parse time */ + existing = node->u.record_entries->elts; + for (i = 0; i < node->u.record_entries->nelts; i++) { + if (php_cedar_str_eq(&existing[i].key, &key)) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "duplicate record key"); + ctx->error = 1; + node = NULL; + goto out; + } + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_COLON) + != PHP_CEDAR_OK) + { + node = NULL; + goto out; + } + + entry = php_cedar_array_push(node->u.record_entries); + if (entry == NULL) { + ctx->error = 1; + node = NULL; + goto out; + } + + entry->key = key; + entry->value = php_cedar_parse_expr(ctx); + if (ctx->error) { + node = NULL; + goto out; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_COMMA) { + php_cedar_parser_advance(ctx); + /* trailing comma: stop if next token is `}` */ + if (ctx->current.type == PHP_CEDAR_TOKEN_RBRACE) { + break; + } + continue; + } + + break; + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RBRACE) + != PHP_CEDAR_OK) + { + node = NULL; + goto out; + } + +out: + ctx->record_depth--; + return node; +} + + +/* parse primary expression */ +static php_cedar_node_t * +php_cedar_parse_primary(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *node; + php_cedar_str_t ident; + + switch (ctx->current.type) { + + case PHP_CEDAR_TOKEN_TRUE: + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_BOOL_LIT); + if (node == NULL) { + return NULL; + } + node->u.bool_val = 1; + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_FALSE: + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_BOOL_LIT); + if (node == NULL) { + return NULL; + } + node->u.bool_val = 0; + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_NUMBER: + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_LONG_LIT); + if (node == NULL) { + return NULL; + } + if (php_cedar_parse_long(&ctx->current.value, + &node->u.long_val) != PHP_CEDAR_OK) + { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: integer overflow"); + ctx->error = 1; + return NULL; + } + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_STRING: + if (ctx->current.has_star_escape) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "invalid escape sequence \\*: " + "only valid in like patterns"); + ctx->error = 1; + return NULL; + } + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_STRING_LIT); + if (node == NULL) { + return NULL; + } + node->u.string_val = ctx->current.value; + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_PRINCIPAL: + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_VAR); + if (node == NULL) { + return NULL; + } + node->u.var_type = PHP_CEDAR_VAR_PRINCIPAL; + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_ACTION: + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_VAR); + if (node == NULL) { + return NULL; + } + node->u.var_type = PHP_CEDAR_VAR_ACTION; + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_RESOURCE: + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_VAR); + if (node == NULL) { + return NULL; + } + node->u.var_type = PHP_CEDAR_VAR_RESOURCE; + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_CONTEXT: + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_VAR); + if (node == NULL) { + return NULL; + } + node->u.var_type = PHP_CEDAR_VAR_CONTEXT; + php_cedar_parser_advance(ctx); + return node; + + case PHP_CEDAR_TOKEN_IP: + php_cedar_parser_advance(ctx); + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_LPAREN) + != PHP_CEDAR_OK) + { + return NULL; + } + if (ctx->current.type != PHP_CEDAR_TOKEN_STRING) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "ip() requires a string argument"); + ctx->error = 1; + return NULL; + } + if (ctx->current.has_star_escape) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "invalid escape sequence \\*: " + "only valid in like patterns"); + ctx->error = 1; + return NULL; + } + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_IP_LITERAL); + if (node == NULL) { + return NULL; + } + node->u.ip_literal.addr = ctx->current.value; + php_cedar_parser_advance(ctx); + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RPAREN) + != PHP_CEDAR_OK) + { + return NULL; + } + return node; + + case PHP_CEDAR_TOKEN_DECIMAL: + php_cedar_parser_advance(ctx); + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_LPAREN) + != PHP_CEDAR_OK) + { + return NULL; + } + if (ctx->current.type != PHP_CEDAR_TOKEN_STRING) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "decimal() requires a string argument"); + ctx->error = 1; + return NULL; + } + if (ctx->current.has_star_escape) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "invalid escape sequence \\*: " + "only valid in like patterns"); + ctx->error = 1; + return NULL; + } + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_DECIMAL_LITERAL); + if (node == NULL) { + return NULL; + } + node->u.decimal_literal.text = ctx->current.value; + php_cedar_parser_advance(ctx); + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RPAREN) + != PHP_CEDAR_OK) + { + return NULL; + } + return node; + + case PHP_CEDAR_TOKEN_IDENT: + ident = ctx->current.value; + php_cedar_parser_advance(ctx); + return php_cedar_parse_entity_ref_with_ident(ctx, ident); + + case PHP_CEDAR_TOKEN_LPAREN: + php_cedar_parser_advance(ctx); + node = php_cedar_parse_expr(ctx); + if (ctx->error) { + return NULL; + } + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RPAREN) + != PHP_CEDAR_OK) + { + return NULL; + } + return node; + + case PHP_CEDAR_TOKEN_LBRACKET: + return php_cedar_parse_set_literal(ctx); + + case PHP_CEDAR_TOKEN_LBRACE: + return php_cedar_parse_record_literal(ctx); + + default: + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: unexpected token %d in expression", + ctx->current.type); + ctx->error = 1; + return NULL; + } +} + + +/* + * Parse one bracket-access step: `[ STRING ]`. + * Called with `[` as the current token; returns a new + * PHP_CEDAR_NODE_ATTR_ACCESS node wrapping `object` on success, + * or NULL with ctx->error set on failure. + */ +static php_cedar_node_t * +php_cedar_parse_bracket_step(php_cedar_parser_ctx_t *ctx, + php_cedar_node_t *object) +{ + php_cedar_node_t *access; + php_cedar_str_t attr; + + php_cedar_parser_advance(ctx); /* consume '[' */ + + if (php_cedar_parser_consume_attr_name_string(ctx, &attr) != PHP_CEDAR_OK) { + return NULL; + } + + /* + * Reject empty-string keys at parse time so the failure location + * is reported by the parser rather than deferred to attribute + * lookup during evaluation. + */ + if (attr.len == 0) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "bracket access key must be non-empty"); + ctx->error = 1; + return NULL; + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RBRACKET) != PHP_CEDAR_OK) { + return NULL; + } + + access = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_ATTR_ACCESS); + if (access == NULL) { + return NULL; + } + + access->u.attr_access.object = object; + access->u.attr_access.attr = attr; + + return access; +} + + +/* + * Parse one dot-access step: `.ident` (attribute access) or + * `.ident(args)` (method call). Called with `.` as the current token; + * returns a new node wrapping `object` on success, or NULL with + * ctx->error set on failure. + */ +static php_cedar_node_t * +php_cedar_parse_dot_step(php_cedar_parser_ctx_t *ctx, + php_cedar_node_t *object) +{ + php_cedar_node_t *access; + php_cedar_str_t ident; + + php_cedar_parser_advance(ctx); /* consume '.' */ + + if (!php_cedar_token_is_ident(ctx->current.type)) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected identifier after '.'"); + ctx->error = 1; + return NULL; + } + + ident = ctx->current.value; + php_cedar_parser_advance(ctx); + + /* method call: expr.method(arg) or expr.method() */ + if (ctx->current.type == PHP_CEDAR_TOKEN_LPAREN) { + php_cedar_node_t *call; + + php_cedar_parser_advance(ctx); + + call = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_METHOD_CALL); + if (call == NULL) { + return NULL; + } + + call->u.method_call.object = object; + call->u.method_call.method = ident; + + if (ctx->current.type == PHP_CEDAR_TOKEN_RPAREN) { + call->u.method_call.arg = NULL; + + } else { + call->u.method_call.arg = php_cedar_parse_expr(ctx); + if (ctx->error) { + return NULL; + } + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RPAREN) != PHP_CEDAR_OK) { + return NULL; + } + + return call; + } + + /* attribute access: expr.ident */ + access = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_ATTR_ACCESS); + if (access == NULL) { + return NULL; + } + + access->u.attr_access.object = object; + access->u.attr_access.attr = ident; + + return access; +} + + +/* + * parse member expression: + * primary { .ident | .ident(args) | [ STRING ] } + * + * Bracket access `expr["key"]` is semantically equivalent to `expr.key`; + * both produce an PHP_CEDAR_NODE_ATTR_ACCESS node. Bracket access + * supports attribute names that are not valid identifiers + * (e.g. hyphenated "X-Request-Id") or that collide with keywords. + */ +static php_cedar_node_t * +php_cedar_parse_member_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *node; + php_cedar_uint_t chain; + + node = php_cedar_parse_primary(ctx); + if (ctx->error) { + return NULL; + } + + chain = 0; + + while (ctx->current.type == PHP_CEDAR_TOKEN_DOT + || ctx->current.type == PHP_CEDAR_TOKEN_LBRACKET) + { + if (++chain > PHP_CEDAR_MAX_MEMBER_CHAIN) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many member access levels"); + ctx->error = 1; + return NULL; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_LBRACKET) { + node = php_cedar_parse_bracket_step(ctx, node); + } else { + node = php_cedar_parse_dot_step(ctx, node); + } + + if (node == NULL) { + return NULL; + } + } + + return node; +} + + +/* parse mult expression: unary { * unary } */ +static php_cedar_node_t * +php_cedar_parse_mult_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *left, *right, *binop; + php_cedar_uint_t chain; + + left = php_cedar_parse_unary_expr(ctx); + if (ctx->error) { + return NULL; + } + + chain = 0; + + while (ctx->current.type == PHP_CEDAR_TOKEN_STAR) { + if (++chain > PHP_CEDAR_MAX_BINOP_CHAIN) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many chained * operators"); + ctx->error = 1; + return NULL; + } + + php_cedar_parser_advance(ctx); + + right = php_cedar_parse_unary_expr(ctx); + if (ctx->error) { + return NULL; + } + + binop = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_BINOP); + if (binop == NULL) { + return NULL; + } + + binop->u.binop.op = PHP_CEDAR_OP_MUL; + binop->u.binop.left = left; + binop->u.binop.right = right; + left = binop; + } + + return left; +} + + +/* parse add expression: mult { (+ | -) mult } */ +static php_cedar_node_t * +php_cedar_parse_add_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *left, *right, *binop; + php_cedar_uint_t op, chain; + + left = php_cedar_parse_mult_expr(ctx); + if (ctx->error) { + return NULL; + } + + chain = 0; + + while (ctx->current.type == PHP_CEDAR_TOKEN_PLUS + || ctx->current.type == PHP_CEDAR_TOKEN_MINUS) + { + if (++chain > PHP_CEDAR_MAX_BINOP_CHAIN) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "too many chained + or - operators"); + ctx->error = 1; + return NULL; + } + + op = (ctx->current.type == PHP_CEDAR_TOKEN_PLUS) + ? PHP_CEDAR_OP_PLUS : PHP_CEDAR_OP_MINUS; + php_cedar_parser_advance(ctx); + + right = php_cedar_parse_mult_expr(ctx); + if (ctx->error) { + return NULL; + } + + binop = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_BINOP); + if (binop == NULL) { + return NULL; + } + + binop->u.binop.op = op; + binop->u.binop.left = left; + binop->u.binop.right = right; + left = binop; + } + + return left; +} + + +/* parse relation expression: add [ relop add | has | like | is ] */ +static php_cedar_node_t * +php_cedar_parse_relation_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *left, *right, *binop, *has_node; + php_cedar_uint_t op; + + left = php_cedar_parse_add_expr(ctx); + if (ctx->error) { + return NULL; + } + + /* has operator: expr has (IDENT | STRING) */ + if (ctx->current.type == PHP_CEDAR_TOKEN_HAS) { + php_cedar_str_t attr; + + php_cedar_parser_advance(ctx); + + if (ctx->current.type == PHP_CEDAR_TOKEN_STRING) { + if (php_cedar_parser_consume_attr_name_string(ctx, &attr) + != PHP_CEDAR_OK) + { + return NULL; + } + + } else if (php_cedar_token_is_ident(ctx->current.type)) { + attr = ctx->current.value; + php_cedar_parser_advance(ctx); + + } else { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "expected identifier or string after 'has'"); + ctx->error = 1; + return NULL; + } + + has_node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_HAS); + if (has_node == NULL) { + return NULL; + } + + has_node->u.has.object = left; + has_node->u.has.attr = attr; + + return has_node; + } + + /* is operator: expr is type_name [in expr] */ + if (ctx->current.type == PHP_CEDAR_TOKEN_IS) { + php_cedar_node_t *is_node; + + php_cedar_parser_advance(ctx); /* consume is */ + + is_node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_IS); + if (is_node == NULL) { + return NULL; + } + + is_node->u.is_check.object = left; + is_node->u.is_check.in_entity = NULL; + + if (php_cedar_parse_type_name(ctx, + &is_node->u.is_check.entity_type) + != PHP_CEDAR_OK) + { + return NULL; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_IN) { + php_cedar_parser_advance(ctx); + + is_node->u.is_check.in_entity = + php_cedar_parse_add_expr(ctx); + if (ctx->error) { + return NULL; + } + } + + return is_node; + } + + /* like operator: expr like STRING */ + if (ctx->current.type == PHP_CEDAR_TOKEN_LIKE) { + php_cedar_node_t *like_node; + + php_cedar_parser_advance(ctx); + + if (ctx->current.type != PHP_CEDAR_TOKEN_STRING) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "expected string pattern after 'like'"); + ctx->error = 1; + return NULL; + } + + like_node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_LIKE); + if (like_node == NULL) { + return NULL; + } + + like_node->u.like.object = left; + + if (php_cedar_parser_compile_pattern(ctx, + &ctx->current.raw, + &like_node->u.like.pattern) + != PHP_CEDAR_OK) + { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "invalid like pattern"); + return NULL; + } + + php_cedar_parser_advance(ctx); + + return like_node; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_EQ) { + op = PHP_CEDAR_OP_EQ; + } else if (ctx->current.type == PHP_CEDAR_TOKEN_NE) { + op = PHP_CEDAR_OP_NE; + } else if (ctx->current.type == PHP_CEDAR_TOKEN_IN) { + op = PHP_CEDAR_OP_IN; + } else if (ctx->current.type == PHP_CEDAR_TOKEN_LT) { + op = PHP_CEDAR_OP_LT; + } else if (ctx->current.type == PHP_CEDAR_TOKEN_GT) { + op = PHP_CEDAR_OP_GT; + } else if (ctx->current.type == PHP_CEDAR_TOKEN_LE) { + op = PHP_CEDAR_OP_LE; + } else if (ctx->current.type == PHP_CEDAR_TOKEN_GE) { + op = PHP_CEDAR_OP_GE; + } else { + return left; + } + + php_cedar_parser_advance(ctx); + + right = php_cedar_parse_add_expr(ctx); + if (ctx->error) { + return NULL; + } + + binop = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_BINOP); + if (binop == NULL) { + return NULL; + } + + binop->u.binop.op = op; + binop->u.binop.left = left; + binop->u.binop.right = right; + + return binop; +} + + +/* parse unary expression: [! | -] unary | relation */ +static php_cedar_node_t * +php_cedar_parse_unary_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *node, *operand; + + if (ctx->current.type == PHP_CEDAR_TOKEN_MINUS) { + php_cedar_parser_advance(ctx); + + /* fold -literal into single negative LONG_LIT */ + if (ctx->current.type == PHP_CEDAR_TOKEN_NUMBER) { + node = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_LONG_LIT); + if (node == NULL) { + return NULL; + } + + if (php_cedar_parse_neg_long(&ctx->current.value, + &node->u.long_val) != PHP_CEDAR_OK) + { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: integer overflow"); + ctx->error = 1; + return NULL; + } + + php_cedar_parser_advance(ctx); + return node; + } + + /* non-literal operand: wrap in NEGATE node */ + ctx->depth++; + + if (ctx->depth > PHP_CEDAR_MAX_PARSE_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expression too deeply nested"); + ctx->error = 1; + ctx->depth--; + return NULL; + } + + operand = php_cedar_parse_unary_expr(ctx); + ctx->depth--; + + if (ctx->error) { + return NULL; + } + + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_NEGATE); + if (node == NULL) { + return NULL; + } + + node->u.unop.operand = operand; + return node; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_NOT) { + php_cedar_parser_advance(ctx); + + ctx->depth++; + + if (ctx->depth > PHP_CEDAR_MAX_PARSE_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expression too deeply nested"); + ctx->error = 1; + ctx->depth--; + return NULL; + } + + operand = php_cedar_parse_unary_expr(ctx); + ctx->depth--; + + if (ctx->error) { + return NULL; + } + + node = php_cedar_parser_alloc_node(ctx, PHP_CEDAR_NODE_UNOP); + if (node == NULL) { + return NULL; + } + + node->u.unop.operand = operand; + return node; + } + + return php_cedar_parse_member_expr(ctx); +} + + +/* parse and expression: relation { && relation } */ +static php_cedar_node_t * +php_cedar_parse_and_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *left, *right, *binop; + php_cedar_uint_t chain; + + left = php_cedar_parse_relation_expr(ctx); + if (ctx->error) { + return NULL; + } + + chain = 0; + + while (ctx->current.type == PHP_CEDAR_TOKEN_AND) { + if (++chain > PHP_CEDAR_MAX_BINOP_CHAIN) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many chained && operators"); + ctx->error = 1; + return NULL; + } + + php_cedar_parser_advance(ctx); + + right = php_cedar_parse_relation_expr(ctx); + if (ctx->error) { + return NULL; + } + + binop = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_BINOP); + if (binop == NULL) { + return NULL; + } + + binop->u.binop.op = PHP_CEDAR_OP_AND; + binop->u.binop.left = left; + binop->u.binop.right = right; + left = binop; + } + + return left; +} + + +/* parse or expression: and { || and } */ +static php_cedar_node_t * +php_cedar_parse_or_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *left, *right, *binop; + php_cedar_uint_t chain; + + left = php_cedar_parse_and_expr(ctx); + if (ctx->error) { + return NULL; + } + + chain = 0; + + while (ctx->current.type == PHP_CEDAR_TOKEN_OR) { + if (++chain > PHP_CEDAR_MAX_BINOP_CHAIN) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many chained || operators"); + ctx->error = 1; + return NULL; + } + + php_cedar_parser_advance(ctx); + + right = php_cedar_parse_and_expr(ctx); + if (ctx->error) { + return NULL; + } + + binop = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_BINOP); + if (binop == NULL) { + return NULL; + } + + binop->u.binop.op = PHP_CEDAR_OP_OR; + binop->u.binop.left = left; + binop->u.binop.right = right; + left = binop; + } + + return left; +} + + +/* parse expression (top-level): if-then-else | or_expr */ +static php_cedar_node_t * +php_cedar_parse_expr(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_node_t *node; + + ctx->depth++; + + if (ctx->depth > PHP_CEDAR_MAX_PARSE_DEPTH) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expression too deeply nested"); + ctx->error = 1; + ctx->depth--; + return NULL; + } + + /* if-then-else expression */ + if (ctx->current.type == PHP_CEDAR_TOKEN_IF) { + php_cedar_node_t *ite; + + php_cedar_parser_advance(ctx); + + ite = php_cedar_parser_alloc_node(ctx, + PHP_CEDAR_NODE_IF_THEN_ELSE); + if (ite == NULL) { + ctx->depth--; + return NULL; + } + + ite->u.if_then_else.cond = php_cedar_parse_expr(ctx); + if (ctx->error) { + ctx->depth--; + return NULL; + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_THEN) + != PHP_CEDAR_OK) + { + ctx->depth--; + return NULL; + } + + ite->u.if_then_else.then_expr = php_cedar_parse_expr(ctx); + if (ctx->error) { + ctx->depth--; + return NULL; + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_ELSE) + != PHP_CEDAR_OK) + { + ctx->depth--; + return NULL; + } + + ite->u.if_then_else.else_expr = php_cedar_parse_expr(ctx); + if (ctx->error) { + ctx->depth--; + return NULL; + } + + ctx->depth--; + return ite; + } + + node = php_cedar_parse_or_expr(ctx); + ctx->depth--; + + return node; +} + + +/* validate that all elements in a scope set are entity refs */ +static php_cedar_int_t +php_cedar_parser_validate_scope_set(php_cedar_parser_ctx_t *ctx, + php_cedar_node_t *node) +{ + php_cedar_node_t **elts; + php_cedar_uint_t i; + + if (node->type != PHP_CEDAR_NODE_SET) { + return PHP_CEDAR_OK; + } + + if (node->u.set_elts == NULL) { + return PHP_CEDAR_OK; + } + + elts = node->u.set_elts->elts; + + for (i = 0; i < node->u.set_elts->nelts; i++) { + if (elts[i]->type != PHP_CEDAR_NODE_ENTITY_REF) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: scope set must contain" + " only entity references"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + } + + return PHP_CEDAR_OK; +} + + +/* parse entity_ref or set literal for scope targets */ +static php_cedar_node_t * +php_cedar_parse_entity_or_set(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_str_t ident; + + if (ctx->current.type == PHP_CEDAR_TOKEN_LBRACKET) { + return php_cedar_parse_set_literal(ctx); + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_IDENT) { + ident = ctx->current.value; + php_cedar_parser_advance(ctx); + return php_cedar_parse_entity_ref_with_ident(ctx, ident); + } + + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected entity ref or set in scope"); + ctx->error = 1; + return NULL; +} + + +/* parse entity_ref only (no set literal) */ +static php_cedar_node_t * +php_cedar_parse_entity_ref_target(php_cedar_parser_ctx_t *ctx) +{ + php_cedar_str_t ident; + + if (ctx->current.type == PHP_CEDAR_TOKEN_IDENT) { + ident = ctx->current.value; + php_cedar_parser_advance(ctx); + return php_cedar_parse_entity_ref_with_ident(ctx, ident); + } + + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: expected entity ref in scope"); + ctx->error = 1; + return NULL; +} + + +/* + * parse scope: keyword [ (== | in) target | is type_name [in entity_ref] ] + * + * Cedar spec: + * == always takes entity_ref. + * in takes entity_ref (all scopes) or set_literal (action only). + * is/is-in is only allowed on principal and resource (not action). + */ +static php_cedar_int_t +php_cedar_parse_scope(php_cedar_parser_ctx_t *ctx, + php_cedar_token_type_t var_token, php_cedar_scope_t *scope) +{ + if (php_cedar_parser_expect(ctx, var_token) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_EQ) { + scope->constraint = PHP_CEDAR_SCOPE_EQ; + php_cedar_parser_advance(ctx); + + /* == always takes entity_ref only */ + scope->target = php_cedar_parse_entity_ref_target(ctx); + if (ctx->error) { + return PHP_CEDAR_ERROR; + } + + } else if (ctx->current.type == PHP_CEDAR_TOKEN_IN) { + scope->constraint = PHP_CEDAR_SCOPE_IN; + php_cedar_parser_advance(ctx); + + if (var_token == PHP_CEDAR_TOKEN_ACTION) { + /* action: entity_ref or set_literal */ + scope->target = php_cedar_parse_entity_or_set(ctx); + + if (ctx->error) { + return PHP_CEDAR_ERROR; + } + + if (php_cedar_parser_validate_scope_set(ctx, + scope->target) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + } else { + /* principal/resource: entity_ref only */ + scope->target = php_cedar_parse_entity_ref_target(ctx); + + if (ctx->error) { + return PHP_CEDAR_ERROR; + } + } + + } else if (ctx->current.type == PHP_CEDAR_TOKEN_IS) { + if (var_token == PHP_CEDAR_TOKEN_ACTION) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "'is' is not allowed in action scope"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + php_cedar_parser_advance(ctx); /* consume is */ + + if (php_cedar_parse_type_name(ctx, &scope->entity_type) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + if (ctx->current.type == PHP_CEDAR_TOKEN_IN) { + scope->constraint = PHP_CEDAR_SCOPE_IS_IN; + php_cedar_parser_advance(ctx); + + scope->target = php_cedar_parse_entity_ref_target(ctx); + if (ctx->error) { + return PHP_CEDAR_ERROR; + } + + } else { + scope->constraint = PHP_CEDAR_SCOPE_IS; + scope->target = NULL; + } + + } else { + scope->constraint = PHP_CEDAR_SCOPE_NONE; + scope->target = NULL; + } + + return PHP_CEDAR_OK; +} + + +/* parse condition: (when | unless) { expr } */ +static php_cedar_int_t +php_cedar_parse_condition(php_cedar_parser_ctx_t *ctx, + php_cedar_condition_t *cond) +{ + if (ctx->current.type == PHP_CEDAR_TOKEN_UNLESS) { + cond->is_unless = 1; + } else { + cond->is_unless = 0; + } + + php_cedar_parser_advance(ctx); + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_LBRACE) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + cond->expr = php_cedar_parse_expr(ctx); + if (ctx->error) { + return PHP_CEDAR_ERROR; + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RBRACE) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + return PHP_CEDAR_OK; +} + + +/* parse annotations: { "@" IDENT [ "(" STRING ")" ] } */ +static php_cedar_int_t +php_cedar_parse_annotations(php_cedar_parser_ctx_t *ctx, + php_cedar_policy_t *policy) +{ + php_cedar_annotation_t *ann; + php_cedar_annotation_t *elts; + php_cedar_uint_t i, count; + + policy->annotations = NULL; + count = 0; + + while (ctx->current.type == PHP_CEDAR_TOKEN_AT) { + + if (++count > PHP_CEDAR_MAX_ANNOTATIONS) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many annotations"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + php_cedar_parser_advance(ctx); /* consume @ */ + + /* expect identifier for annotation key */ + if (!php_cedar_token_is_ident(ctx->current.type)) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "expected identifier after '@'"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + /* lazy-create annotations array */ + if (policy->annotations == NULL) { + policy->annotations = php_cedar_array_create(ctx->pool, 4, + sizeof(php_cedar_annotation_t)); + if (policy->annotations == NULL) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + } + + ann = php_cedar_array_push(policy->annotations); + if (ann == NULL) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + ann->key = ctx->current.value; + ann->value.data = NULL; + ann->value.len = 0; + + php_cedar_parser_advance(ctx); /* consume IDENT */ + + /* optional value: "(" STRING ")" */ + if (ctx->current.type == PHP_CEDAR_TOKEN_LPAREN) { + php_cedar_parser_advance(ctx); /* consume ( */ + + if (php_cedar_parser_consume_attr_name_string(ctx, &ann->value) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + if (php_cedar_parser_expect(ctx, + PHP_CEDAR_TOKEN_RPAREN) != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + } + + /* duplicate key check */ + if (policy->annotations->nelts > 1) { + elts = policy->annotations->elts; + + for (i = 0; i < policy->annotations->nelts - 1; i++) { + if (elts[i].key.len == ann->key.len + && php_cedar_memcmp(elts[i].key.data, + ann->key.data, + ann->key.len) == 0) + { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: " + "duplicate annotation key"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + } + } + } + + return PHP_CEDAR_OK; +} + + +/* parse a single policy */ +static php_cedar_int_t +php_cedar_parse_policy(php_cedar_parser_ctx_t *ctx, + php_cedar_policy_t *policy) +{ + php_cedar_condition_t *cond; + php_cedar_uint_t nconds; + + /* effect */ + if (ctx->current.type == PHP_CEDAR_TOKEN_FORBID) { + policy->is_forbid = 1; + } else { + policy->is_forbid = 0; + } + + php_cedar_parser_advance(ctx); + + /* ( */ + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_LPAREN) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + /* principal */ + if (php_cedar_parse_scope(ctx, PHP_CEDAR_TOKEN_PRINCIPAL, + &policy->principal) != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_COMMA) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + + /* action */ + if (php_cedar_parse_scope(ctx, PHP_CEDAR_TOKEN_ACTION, + &policy->action) != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_COMMA) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + + /* resource */ + if (php_cedar_parse_scope(ctx, PHP_CEDAR_TOKEN_RESOURCE, + &policy->resource) != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + /* ) */ + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_RPAREN) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + /* conditions */ + policy->conditions = php_cedar_array_create(ctx->pool, 2, + sizeof(php_cedar_condition_t)); + if (policy->conditions == NULL) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + nconds = 0; + + while (ctx->current.type == PHP_CEDAR_TOKEN_WHEN + || ctx->current.type == PHP_CEDAR_TOKEN_UNLESS) + { + if (++nconds > PHP_CEDAR_MAX_CONDITIONS) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, ctx->log, 0, + "php_cedar_parse: too many conditions per policy"); + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + cond = php_cedar_array_push(policy->conditions); + if (cond == NULL) { + ctx->error = 1; + return PHP_CEDAR_ERROR; + } + + if (php_cedar_parse_condition(ctx, cond) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + } + + /* ; */ + if (php_cedar_parser_expect(ctx, PHP_CEDAR_TOKEN_SEMICOLON) + != PHP_CEDAR_OK) + { + return PHP_CEDAR_ERROR; + } + + return PHP_CEDAR_OK; +} + + +php_cedar_policy_set_t * +php_cedar_parse(php_cedar_pool_t *pool, php_cedar_log_t *log, const php_cedar_str_t *text) +{ + php_cedar_parser_ctx_t ctx; + php_cedar_policy_set_t *ps; + php_cedar_policy_t *policy; + + if (pool == NULL || log == NULL || text == NULL) { + return NULL; + } + + php_cedar_memzero(&ctx, sizeof(php_cedar_parser_ctx_t)); + ctx.pool = pool; + ctx.log = log; + + php_cedar_lexer_init(&ctx.lexer, pool, log, text); + php_cedar_parser_advance(&ctx); + + ps = php_cedar_pcalloc(pool, sizeof(php_cedar_policy_set_t)); + if (ps == NULL) { + return NULL; + } + + ps->policies = php_cedar_array_create(pool, 4, + sizeof(php_cedar_policy_t)); + if (ps->policies == NULL) { + return NULL; + } + + while (ctx.current.type == PHP_CEDAR_TOKEN_PERMIT + || ctx.current.type == PHP_CEDAR_TOKEN_FORBID + || ctx.current.type == PHP_CEDAR_TOKEN_AT) + { + if (ps->policies->nelts >= PHP_CEDAR_MAX_POLICIES) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, log, 0, + "php_cedar_parse: too many policies (max %d)", + PHP_CEDAR_MAX_POLICIES); + return NULL; + } + + policy = php_cedar_array_push(ps->policies); + if (policy == NULL) { + return NULL; + } + + php_cedar_memzero(policy, sizeof(php_cedar_policy_t)); + + /* parse annotations before effect (Phase 4) */ + if (php_cedar_parse_annotations(&ctx, policy) != PHP_CEDAR_OK) { + return NULL; + } + + if (ctx.current.type != PHP_CEDAR_TOKEN_PERMIT + && ctx.current.type != PHP_CEDAR_TOKEN_FORBID) + { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, log, 0, + "php_cedar_parse: " + "expected permit or forbid after annotations"); + return NULL; + } + + if (php_cedar_parse_policy(&ctx, policy) != PHP_CEDAR_OK) { + return NULL; + } + } + + if (ctx.current.type != PHP_CEDAR_TOKEN_EOF) { + php_cedar_log_error(PHP_CEDAR_LOG_ERR, log, 0, + "php_cedar_parse: unexpected token after policies"); + return NULL; + } + + return ps; +} diff --git a/src/cedar/php_cedar_parser.h b/src/cedar/php_cedar_parser.h new file mode 100644 index 0000000..63575e9 --- /dev/null +++ b/src/cedar/php_cedar_parser.h @@ -0,0 +1,29 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_parser.h - Cedar policy text recursive descent parser + */ + +#ifndef PHP_CEDAR_PARSER_H +#define PHP_CEDAR_PARSER_H + +#include "php_cedar_types.h" + + +/* + * Parse a Cedar policy-set text into an AST. + * + * All three arguments are required: pool / log / text must be non-NULL. + * Passing NULL for any of them returns NULL without dereferencing it + * (defensive guard against caller mistakes; the implementation also + * relies on these being non-NULL once it starts allocating). + * + * Returns NULL on parse error or allocation failure as well; details + * are logged to `log`. + */ +php_cedar_policy_set_t *php_cedar_parse(php_cedar_pool_t *pool, + php_cedar_log_t *log, const php_cedar_str_t *text); + + +#endif /* PHP_CEDAR_PARSER_H */ diff --git a/src/cedar/php_cedar_types.h b/src/cedar/php_cedar_types.h new file mode 100644 index 0000000..99ea272 --- /dev/null +++ b/src/cedar/php_cedar_types.h @@ -0,0 +1,515 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_types.h - data structure definitions for nxe-cedar + * + * Type definitions used by the Cedar policy language subset + * implementation in C. No implementation code, types only. + */ + +#ifndef PHP_CEDAR_TYPES_H +#define PHP_CEDAR_TYPES_H + +#include "php_cedar_compat.h" +#include + + +/* --- decision result --- */ + +typedef enum { + PHP_CEDAR_DECISION_DENY = 0, /* deny (default) */ + PHP_CEDAR_DECISION_ALLOW = 1 /* allow */ +} php_cedar_decision_t; + + +/* --- token types --- */ + +typedef enum { + /* keywords */ + PHP_CEDAR_TOKEN_PERMIT, + PHP_CEDAR_TOKEN_FORBID, + PHP_CEDAR_TOKEN_WHEN, + PHP_CEDAR_TOKEN_UNLESS, + PHP_CEDAR_TOKEN_PRINCIPAL, + PHP_CEDAR_TOKEN_ACTION, + PHP_CEDAR_TOKEN_RESOURCE, + PHP_CEDAR_TOKEN_CONTEXT, + PHP_CEDAR_TOKEN_TRUE, + PHP_CEDAR_TOKEN_FALSE, + PHP_CEDAR_TOKEN_IN, + PHP_CEDAR_TOKEN_IF, /* Phase 2 */ + PHP_CEDAR_TOKEN_THEN, /* Phase 2 */ + PHP_CEDAR_TOKEN_ELSE, /* Phase 2 */ + PHP_CEDAR_TOKEN_HAS, /* Phase 2 */ + PHP_CEDAR_TOKEN_LIKE, /* Phase 2 */ + PHP_CEDAR_TOKEN_IP, /* Phase 3 */ + PHP_CEDAR_TOKEN_DECIMAL, /* Phase 3 */ + PHP_CEDAR_TOKEN_IS, /* Phase 4 */ + + /* operators */ + PHP_CEDAR_TOKEN_EQ, /* == */ + PHP_CEDAR_TOKEN_NE, /* != */ + PHP_CEDAR_TOKEN_AND, /* && */ + PHP_CEDAR_TOKEN_OR, /* || */ + PHP_CEDAR_TOKEN_NOT, /* ! */ + PHP_CEDAR_TOKEN_MINUS, /* - (binary and unary; Phase 4) */ + PHP_CEDAR_TOKEN_PLUS, /* + (Phase 4) */ + PHP_CEDAR_TOKEN_STAR, /* * (Phase 4) */ + PHP_CEDAR_TOKEN_LT, /* < (Phase 2) */ + PHP_CEDAR_TOKEN_GT, /* > (Phase 2) */ + PHP_CEDAR_TOKEN_LE, /* <= (Phase 2) */ + PHP_CEDAR_TOKEN_GE, /* >= (Phase 2) */ + + /* delimiters */ + PHP_CEDAR_TOKEN_DOT, /* . */ + PHP_CEDAR_TOKEN_COMMA, /* , */ + PHP_CEDAR_TOKEN_SEMICOLON, /* ; */ + PHP_CEDAR_TOKEN_LPAREN, /* ( */ + PHP_CEDAR_TOKEN_RPAREN, /* ) */ + PHP_CEDAR_TOKEN_LBRACE, /* { */ + PHP_CEDAR_TOKEN_RBRACE, /* } */ + PHP_CEDAR_TOKEN_LBRACKET, /* [ */ + PHP_CEDAR_TOKEN_RBRACKET, /* ] */ + PHP_CEDAR_TOKEN_COLONCOLON, /* :: */ + PHP_CEDAR_TOKEN_COLON, /* : (Phase 4 record literal) */ + PHP_CEDAR_TOKEN_AT, /* @ (Phase 4) */ + + /* literals */ + PHP_CEDAR_TOKEN_STRING, /* "..." */ + PHP_CEDAR_TOKEN_NUMBER, /* [0-9]+ */ + PHP_CEDAR_TOKEN_IDENT, /* identifier */ + + /* special */ + PHP_CEDAR_TOKEN_EOF, + PHP_CEDAR_TOKEN_ERROR +} php_cedar_token_type_t; + + +/* --- token --- */ + +typedef struct { + php_cedar_token_type_t type; + php_cedar_str_t value; /* string representation */ + php_cedar_str_t raw; /* raw source for STRING tokens + (used by like pattern compiler) */ + php_cedar_flag_t has_star_escape; /* 1 if \* found in string */ +} php_cedar_token_t; + + +/* --- binary operators --- */ + +typedef enum { + PHP_CEDAR_OP_EQ, /* == */ + PHP_CEDAR_OP_NE, /* != */ + PHP_CEDAR_OP_AND, /* && */ + PHP_CEDAR_OP_OR, /* || */ + PHP_CEDAR_OP_IN, /* in */ + PHP_CEDAR_OP_LT, /* < (Phase 2) */ + PHP_CEDAR_OP_GT, /* > (Phase 2) */ + PHP_CEDAR_OP_LE, /* <= (Phase 2) */ + PHP_CEDAR_OP_GE, /* >= (Phase 2) */ + PHP_CEDAR_OP_PLUS, /* + (Phase 4) */ + PHP_CEDAR_OP_MINUS, /* - (Phase 4) */ + PHP_CEDAR_OP_MUL /* * (Phase 4) */ +} php_cedar_op_t; + + +/* --- variable types --- */ + +typedef enum { + PHP_CEDAR_VAR_PRINCIPAL = 0, + PHP_CEDAR_VAR_ACTION = 1, + PHP_CEDAR_VAR_RESOURCE = 2, + PHP_CEDAR_VAR_CONTEXT = 3 +} php_cedar_var_type_t; + + +/* --- AST node types --- */ + +typedef enum { + /* literals */ + PHP_CEDAR_NODE_BOOL_LIT, /* true / false */ + PHP_CEDAR_NODE_STRING_LIT, /* "..." */ + PHP_CEDAR_NODE_LONG_LIT, /* integer */ + PHP_CEDAR_NODE_ENTITY_REF, /* Type::"id" */ + PHP_CEDAR_NODE_SET, /* [expr, ...] */ + + /* variables */ + PHP_CEDAR_NODE_VAR, /* principal, action, resource, context */ + + /* operations */ + PHP_CEDAR_NODE_ATTR_ACCESS, /* expr.ident */ + PHP_CEDAR_NODE_BINOP, /* ==, !=, <, >, <=, >=, &&, ||, in, + +, -, * (Phase 4) */ + PHP_CEDAR_NODE_UNOP, /* ! */ + PHP_CEDAR_NODE_NEGATE, /* - (unary) */ + + /* Phase 2 */ + PHP_CEDAR_NODE_HAS, /* expr has ident */ + PHP_CEDAR_NODE_LIKE, /* expr like "pattern" */ + PHP_CEDAR_NODE_IF_THEN_ELSE, /* if expr then expr else expr */ + PHP_CEDAR_NODE_METHOD_CALL, /* expr.method(args) */ + + /* Phase 3 */ + PHP_CEDAR_NODE_IP_LITERAL, /* ip("addr") */ + PHP_CEDAR_NODE_DECIMAL_LITERAL, /* decimal("1.23") */ + + /* Phase 4 */ + PHP_CEDAR_NODE_IS, /* expr is type_name [in expr] */ + PHP_CEDAR_NODE_RECORD /* { key: expr, ... } */ +} php_cedar_node_type_t; + + +/* --- AST node --- */ + +typedef struct php_cedar_node_s php_cedar_node_t; + +/* parse-time record literal entry (key and value expression) */ +typedef struct { + php_cedar_str_t key; + php_cedar_node_t *value; +} php_cedar_record_entry_t; + +struct php_cedar_node_s { + php_cedar_node_type_t type; + union { + php_cedar_flag_t bool_val; /* BOOL_LIT */ + php_cedar_str_t string_val; /* STRING_LIT, LIKE pattern */ + int64_t long_val; /* LONG_LIT (Cedar i64) */ + + struct { /* ENTITY_REF */ + php_cedar_str_t entity_type; + php_cedar_str_t entity_id; + } entity_ref; + + php_cedar_var_type_t var_type; /* VAR */ + + struct { /* ATTR_ACCESS */ + php_cedar_node_t *object; + php_cedar_str_t attr; + } attr_access; + + struct { /* BINOP */ + php_cedar_uint_t op; /* php_cedar_op_t */ + php_cedar_node_t *left; + php_cedar_node_t *right; + } binop; + + struct { /* UNOP */ + php_cedar_node_t *operand; + } unop; + + php_cedar_array_t *set_elts; /* SET: array of + php_cedar_node_t* */ + + struct { /* HAS */ + php_cedar_node_t *object; + php_cedar_str_t attr; + } has; + + struct { /* LIKE */ + php_cedar_node_t *object; + php_cedar_str_t pattern; + } like; + + struct { /* IF_THEN_ELSE */ + php_cedar_node_t *cond; + php_cedar_node_t *then_expr; + php_cedar_node_t *else_expr; + } if_then_else; + + struct { /* METHOD_CALL */ + php_cedar_node_t *object; + php_cedar_str_t method; /* "containsAll", + "containsAny", + "contains", + "isInRange", + "isIpv4", "isIpv6", + "isLoopback", + "isMulticast", + "lessThan", + "lessThanOrEqual", + "greaterThan", + "greaterThanOrEqual" */ + php_cedar_node_t *arg; /* NULL for zero-arg + methods (isIpv4 etc.) */ + } method_call; + + struct { /* IP_LITERAL */ + php_cedar_str_t addr; + } ip_literal; + + struct { /* DECIMAL_LITERAL */ + php_cedar_str_t text; + } decimal_literal; + + struct { /* IS (Phase 4) */ + php_cedar_node_t *object; /* expression under test */ + php_cedar_str_t entity_type; /* type_name + ("User", "Ns::User", ...) */ + php_cedar_node_t *in_entity; /* "is T in expr" expr, + NULL if plain "is T" */ + } is_check; + + php_cedar_array_t *record_entries; /* RECORD: array of + php_cedar_record_entry_t */ + } u; +}; + + +/* --- policy structures --- */ + +/* scope constraint type */ +typedef enum { + PHP_CEDAR_SCOPE_NONE, /* no constraint (matches all) */ + PHP_CEDAR_SCOPE_EQ, /* == entity_ref */ + PHP_CEDAR_SCOPE_IN, /* in entity_ref | set */ + PHP_CEDAR_SCOPE_IS, /* is type_name (Phase 4) */ + PHP_CEDAR_SCOPE_IS_IN /* is type_name in entity_ref + (Phase 4) */ +} php_cedar_scope_constraint_t; + +/* scope constraint */ +typedef struct { + php_cedar_scope_constraint_t constraint; + php_cedar_node_t *target; /* entity_ref or set + (NULL if NONE, IS) */ + php_cedar_str_t entity_type; /* type_name (IS, IS_IN + only; empty otherwise) */ +} php_cedar_scope_t; + +/* annotation (Phase 4) */ +typedef struct { + php_cedar_str_t key; /* annotation name (e.g. "id", "advice") */ + php_cedar_str_t value; /* annotation value; empty if valueless */ +} php_cedar_annotation_t; + +/* condition clause */ +typedef struct { + unsigned is_unless:1; /* 0 = when, 1 = unless */ + php_cedar_node_t *expr; +} php_cedar_condition_t; + +/* single policy */ +typedef struct { + unsigned is_forbid:1; /* 0 = permit, 1 = forbid */ + php_cedar_array_t *annotations; /* array of php_cedar_annotation_t + (Phase 4, NULL if none) */ + php_cedar_scope_t principal; + php_cedar_scope_t action; + php_cedar_scope_t resource; + php_cedar_array_t *conditions; /* array of + php_cedar_condition_t */ +} php_cedar_policy_t; + +/* policy set */ +typedef struct { + php_cedar_array_t *policies; /* array of php_cedar_policy_t */ +} php_cedar_policy_set_t; + + +/* + * Diagnostic detail returned by php_cedar_eval_detail(). + * + * `policies` points to the subset of policies that produced the + * decision: every matching `forbid` when the decision is DENY because + * at least one `forbid` matched, or every matching `permit` when the + * decision is ALLOW. For a default-deny outcome (no policy matched) + * `policies` is NULL and `npolicies` is 0. + * + * Each entry is a pointer to a policy inside the input + * `php_cedar_policy_set_t`; the caller must not reference them past + * the lifetime of that policy set (or of the evaluation pool used to + * allocate the pointer array). + * + * `errored` / `nerrored` are reserved for policies whose conditions + * produced an evaluation error. They are unused in the current + * implementation (always NULL / 0) and reserved for a future revision. + */ +typedef struct { + php_cedar_policy_t **policies; + php_cedar_uint_t npolicies; + php_cedar_policy_t **errored; + php_cedar_uint_t nerrored; +} php_cedar_decision_detail_t; + + +/* --- evaluation context --- */ + +/* + * Parser member-chain limit. Caps `expr.a.b.c...` to this many `.ident` + * or `["key"]` steps. Shared with the record-value nesting limit below + * so the parser's reachable depth and the writable record depth stay + * in sync (bumping one automatically bumps the other). + */ +#define PHP_CEDAR_MAX_MEMBER_CHAIN 16 + +/* + * Record-value nesting limit. Defined as PHP_CEDAR_MAX_MEMBER_CHAIN so + * no record value can be created at a depth that policy text cannot + * reference: a depth-N record is the value returned by an N-step member + * chain, and the parser caps that chain at PHP_CEDAR_MAX_MEMBER_CHAIN. + * Note that reading a scalar (or sub-record) inside a depth-N record + * takes N+1 steps, so scalar fields placed directly inside a + * depth-PHP_CEDAR_MAX_RECORD_DEPTH record are writable but unreachable + * from any policy. Keep deep scalars one level above the limit. + */ +#define PHP_CEDAR_MAX_RECORD_DEPTH PHP_CEDAR_MAX_MEMBER_CHAIN + +/* + * Set-value nesting limit. Mirrors PHP_CEDAR_MAX_RECORD_DEPTH so a + * value graph mixing nested records and sets shares one depth ceiling, + * preventing unbounded recursion in `==` and other value walks. + */ +#define PHP_CEDAR_MAX_SET_DEPTH PHP_CEDAR_MAX_RECORD_DEPTH + +/* + * Independent recursion limit for php_cedar_value_equals(). The function + * recurses into nested set/record elements without going through + * php_cedar_expr_eval(), so ctx->eval_depth does not protect it. In + * practice the structural caps on injected values (MAX_RECORD_DEPTH and + * MAX_SET_DEPTH) and the parser's MAX_PARSE_DEPTH already bound the + * graph, but value_equals deserves its own ceiling as a defense-in-depth + * measure so the safety of one recursive walk is not load-bearing on + * invariants enforced elsewhere. The sum of MAX_RECORD_DEPTH and + * MAX_SET_DEPTH (=32) is the worst case for an alternating record/set + * chain at the injection-API ceiling. + */ +#define PHP_CEDAR_MAX_VALUE_EQUALS_DEPTH \ + (PHP_CEDAR_MAX_RECORD_DEPTH + PHP_CEDAR_MAX_SET_DEPTH) + +/* + * Expression-evaluation recursion limit. AST shape is already bounded + * by the parser (PHP_CEDAR_MAX_PARSE_DEPTH, MAX_MEMBER_CHAIN, + * MAX_BINOP_CHAIN), but recursive walks during evaluation can stack + * AST depth on top of attribute lookups and method-call dispatch, so + * an evaluator-side ceiling is needed too. Each entry into + * php_cedar_expr_eval() increments ctx->eval_depth; once the count + * reaches the limit further entries short-circuit to RVAL_ERROR. + * 128 gives ~2x the parser's PHP_CEDAR_MAX_PARSE_DEPTH (64) so any + * AST the parser accepts evaluates without spuriously hitting the + * cap, while keeping recursion well under typical thread stack sizes. + */ +#define PHP_CEDAR_MAX_EVAL_DEPTH 128 + + +/* --- runtime values --- */ + +/* runtime value types */ +#define PHP_CEDAR_RVAL_STRING 0 +#define PHP_CEDAR_RVAL_LONG 1 +#define PHP_CEDAR_RVAL_BOOL 2 +#define PHP_CEDAR_RVAL_ENTITY 3 +#define PHP_CEDAR_RVAL_SET 4 +#define PHP_CEDAR_RVAL_ERROR 5 +#define PHP_CEDAR_RVAL_IP 6 +#define PHP_CEDAR_RVAL_RECORD 7 +#define PHP_CEDAR_RVAL_DECIMAL 8 + + +/* + * Origin tag on entity values. NONE is the default for derived entities + * (literals, attribute lookups, set elements) and must compare equal to + * zero so php_cedar_memzero-initialized values inherit it. PRINCIPAL / ACTION + * / RESOURCE are stamped only when the value is produced by evaluating + * the corresponding PHP_CEDAR_NODE_VAR. The slot lets `in` evaluation + * pick the matching parents array even when principal / action / + * resource share the same (type, id) — looking up by identity alone + * collapses on collisions and silently picks the first slot. + */ +#define PHP_CEDAR_ENTITY_SLOT_NONE 0 +#define PHP_CEDAR_ENTITY_SLOT_PRINCIPAL 1 +#define PHP_CEDAR_ENTITY_SLOT_ACTION 2 +#define PHP_CEDAR_ENTITY_SLOT_RESOURCE 3 + + +typedef struct { + php_cedar_uint_t type; /* PHP_CEDAR_RVAL_* */ + union { + php_cedar_str_t str_val; + int64_t long_val; /* Cedar i64 runtime value */ + php_cedar_flag_t bool_val; + struct { + php_cedar_str_t type; + php_cedar_str_t id; + php_cedar_uint_t slot; /* PHP_CEDAR_ENTITY_SLOT_* */ + } entity; + php_cedar_array_t *set_elts; /* array of php_cedar_value_t */ + php_cedar_array_t *record_attrs; /* array of php_cedar_attr_t */ + struct { + unsigned char addr[16]; /* network byte order */ + php_cedar_uint_t prefix_len; /* /prefix; single=32(v4)/128(v6) */ + unsigned is_ipv6 :1; + } ip_addr; + /* + * Cedar decimal: fixed-point i64 with implicit scale 10^4. + * "1.23" is stored as 12300, "-0.5" as -5000, "0.0001" as 1. + * Holds the full int64_t range; the parser rejects inputs whose + * scaled value would overflow. + */ + int64_t decimal_val; + } v; +} php_cedar_value_t; + + +/* + * Named runtime value used both as an entity attribute (principal / + * action / resource / context) and as a record entry. + */ +typedef struct { + php_cedar_str_t name; + php_cedar_value_t value; +} php_cedar_attr_t; + + +/* + * Entity reference used to record ancestors / group memberships in the + * evaluation context. Callers supply the transitive closure (including + * indirect ancestors) as a flat list; `in` checks are reflexive. + */ +typedef struct { + php_cedar_str_t type; + php_cedar_str_t id; +} php_cedar_entity_ref_t; + + +/* evaluation context (built per-request) */ +typedef struct { + php_cedar_pool_t *pool; + + /* principal */ + php_cedar_str_t principal_type; + php_cedar_str_t principal_id; + php_cedar_array_t *principal_attrs; /* array of php_cedar_attr_t */ + php_cedar_array_t *principal_parents; /* array of + php_cedar_entity_ref_t */ + + /* action */ + php_cedar_str_t action_type; + php_cedar_str_t action_id; + php_cedar_array_t *action_attrs; /* array of php_cedar_attr_t */ + php_cedar_array_t *action_parents; /* array of + php_cedar_entity_ref_t */ + + /* resource */ + php_cedar_str_t resource_type; + php_cedar_str_t resource_id; + php_cedar_array_t *resource_attrs; /* array of php_cedar_attr_t */ + php_cedar_array_t *resource_parents; /* array of + php_cedar_entity_ref_t */ + + /* context */ + php_cedar_array_t *context_attrs; /* array of php_cedar_attr_t */ + + /* + * Recursion guard for php_cedar_expr_eval(). Incremented at entry + * and decremented at exit; the entry guard returns an RVAL_ERROR + * value before recursing further when the count would exceed + * PHP_CEDAR_MAX_EVAL_DEPTH. + */ + php_cedar_uint_t eval_depth; +} php_cedar_eval_ctx_t; + + +#endif /* PHP_CEDAR_TYPES_H */ diff --git a/src/cedar/php_cedar_util.h b/src/cedar/php_cedar_util.h new file mode 100644 index 0000000..82e11d7 --- /dev/null +++ b/src/cedar/php_cedar_util.h @@ -0,0 +1,28 @@ +/* + * Copyright (c) Tatsuya Kamijo + * Copyright (c) Bengo4.com, Inc. + * + * php_cedar_util.h - Common utility helpers shared across layers + * + * Header for small, dependency-free helpers used by both the parser + * and the evaluator. Keeping them here lets the parser avoid pulling + * in evaluator-only headers solely for tiny utilities. + */ + +#ifndef PHP_CEDAR_UTIL_H +#define PHP_CEDAR_UTIL_H + +#include "php_cedar_types.h" + + +/* string equality (shared across parser/expr/eval layers) */ +static inline php_cedar_int_t +php_cedar_str_eq(php_cedar_str_t *a, php_cedar_str_t *b) +{ + return (a->len == b->len + && (a->len == 0 + || php_cedar_memcmp(a->data, b->data, a->len) == 0)); +} + + +#endif /* PHP_CEDAR_UTIL_H */ From 683c62609c83a6c584022847a555d26d463df2f4 Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 10:55:22 +0900 Subject: [PATCH 02/19] feat: add NGINX type/function compatibility layer Provide php_cedar_compat.{h,c} so the evaluator sources imported from nxe-cedar can build without NGINX. The header defines drop-in replacements for the types and helpers used by the lexer / parser / eval code: - Types: php_cedar_pool_t / php_cedar_str_t / php_cedar_array_t / php_cedar_log_t / php_cedar_int_t / php_cedar_uint_t / php_cedar_flag_t - Helpers: php_cedar_palloc / pcalloc / pool_create / pool_destroy, php_cedar_array_create / array_push, php_cedar_log_error, php_cedar_memcmp / memcpy / memzero - Return codes: PHP_CEDAR_OK / PHP_CEDAR_ERROR / PHP_CEDAR_DECLINED - Log levels: PHP_CEDAR_LOG_EMERG ... PHP_CEDAR_LOG_DEBUG Memory allocation switches at build time. When PHP_CEDAR_USE_ZEND_MM is defined (set by config.m4 in the extension build), the layer routes allocations through the Zend Memory Manager (emalloc/efree) and log output through php_error_docref. Otherwise it falls back to libc (malloc/free + fprintf to stderr), which keeps the layer usable in standalone tests or future non-PHP host integrations. The compatibility implementation includes config.h via HAVE_CONFIG_H so the PHP_CEDAR_USE_ZEND_MM macro is observed when the extension build defines it. After this commit, all src/cedar/ files compile cleanly. --- src/cedar/php_cedar_compat.c | 179 +++++++++++++++++++++++++++++++++++ src/cedar/php_cedar_compat.h | 99 +++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/cedar/php_cedar_compat.c create mode 100644 src/cedar/php_cedar_compat.h diff --git a/src/cedar/php_cedar_compat.c b/src/cedar/php_cedar_compat.c new file mode 100644 index 0000000..dda1a57 --- /dev/null +++ b/src/cedar/php_cedar_compat.c @@ -0,0 +1,179 @@ +/* + * Implementation for the function bodies declared in + * php_cedar_compat.h (pool / array / log). + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "php_cedar_compat.h" + +#include +#include + +#ifdef PHP_CEDAR_USE_ZEND_MM +# include "php.h" +# define PHP_CEDAR_MALLOC(sz) emalloc(sz) +# define PHP_CEDAR_FREE(ptr) efree(ptr) +#else +# include +# define PHP_CEDAR_MALLOC(sz) malloc(sz) +# define PHP_CEDAR_FREE(ptr) free(ptr) +#endif + +/* ---- pool ------------------------------------------------------------ */ + +php_cedar_pool_t * +php_cedar_pool_create(php_cedar_log_t *log) +{ + php_cedar_pool_t *p = (php_cedar_pool_t *) PHP_CEDAR_MALLOC(sizeof(*p)); + if (p == NULL) { + return NULL; + } + p->chunks = NULL; + p->log = log; + return p; +} + +void +php_cedar_pool_destroy(php_cedar_pool_t *pool) +{ + php_cedar_pool_chunk_t *c, *next; + + if (pool == NULL) { + return; + } + for (c = pool->chunks; c != NULL; c = next) { + next = c->next; + PHP_CEDAR_FREE(c->data); + PHP_CEDAR_FREE(c); + } + PHP_CEDAR_FREE(pool); +} + +void * +php_cedar_palloc(php_cedar_pool_t *pool, size_t size) +{ + php_cedar_pool_chunk_t *c; + + if (pool == NULL || size == 0) { + return NULL; + } + c = (php_cedar_pool_chunk_t *) PHP_CEDAR_MALLOC(sizeof(*c)); + if (c == NULL) { + return NULL; + } + c->data = PHP_CEDAR_MALLOC(size); + if (c->data == NULL) { + PHP_CEDAR_FREE(c); + return NULL; + } + c->next = pool->chunks; + pool->chunks = c; + return c->data; +} + +void * +php_cedar_pcalloc(php_cedar_pool_t *pool, size_t size) +{ + void *p = php_cedar_palloc(pool, size); + if (p != NULL) { + memset(p, 0, size); + } + return p; +} + +/* ---- array ----------------------------------------------------------- */ + +php_cedar_array_t * +php_cedar_array_create(php_cedar_pool_t *pool, + php_cedar_uint_t n, size_t size) +{ + php_cedar_array_t *a; + + if (pool == NULL || n == 0 || size == 0) { + return NULL; + } + if ((size_t) n > SIZE_MAX / size) { + return NULL; /* n * size would overflow */ + } + a = (php_cedar_array_t *) php_cedar_palloc(pool, sizeof(*a)); + if (a == NULL) { + return NULL; + } + a->elts = php_cedar_palloc(pool, (size_t) n * size); + if (a->elts == NULL) { + return NULL; + } + a->nelts = 0; + a->size = size; + a->nalloc = n; + a->pool = pool; + return a; +} + +void * +php_cedar_array_push(php_cedar_array_t *a) +{ + void *elt; + + if (a == NULL) { + return NULL; + } + if (a->nelts == a->nalloc) { + php_cedar_uint_t new_alloc = a->nalloc * 2; + void *new_elts; + + if (new_alloc <= a->nalloc) { + return NULL; /* nalloc * 2 wrapped around */ + } + if ((size_t) new_alloc > SIZE_MAX / a->size) { + return NULL; /* new_alloc * size would overflow */ + } + new_elts = php_cedar_palloc(a->pool, (size_t) new_alloc * a->size); + if (new_elts == NULL) { + return NULL; + } + memcpy(new_elts, a->elts, (size_t) a->nelts * a->size); + a->elts = new_elts; + a->nalloc = new_alloc; + } + elt = (char *) a->elts + a->size * a->nelts; + a->nelts++; + return elt; +} + +/* ---- log ------------------------------------------------------------- */ + +void +php_cedar_log_error(int level, php_cedar_log_t *log, + int err, const char *fmt, ...) +{ + char buf[1024]; + va_list ap; + + if (log != NULL && level > log->level) { + return; + } + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + +#ifdef PHP_CEDAR_USE_ZEND_MM + { + int e = (level <= PHP_CEDAR_LOG_ERR) ? E_WARNING : E_NOTICE; + if (err) { + php_error_docref(NULL, e, "%s (errno=%d)", buf, err); + } else { + php_error_docref(NULL, e, "%s", buf); + } + } +#else + if (err) { + fprintf(stderr, "[cedar] %s (errno=%d)\n", buf, err); + } else { + fprintf(stderr, "[cedar] %s\n", buf); + } +#endif +} diff --git a/src/cedar/php_cedar_compat.h b/src/cedar/php_cedar_compat.h new file mode 100644 index 0000000..d496d19 --- /dev/null +++ b/src/cedar/php_cedar_compat.h @@ -0,0 +1,99 @@ +/* + * NGINX type / function compatibility layer. + * + * The sources copied from nxe-cedar (the NGINX-edge Cedar evaluator) + * depend on NGINX symbols such as ngx_pool_t / ngx_str_t / + * ngx_log_error. This header replaces them with PHP-extension-friendly + * equivalents. + * + * Memory management switches to the Zend Memory Manager + * (emalloc/efree) when PHP_CEDAR_USE_ZEND_MM is defined at build time + * (the extension build sets it via config.m4); otherwise it falls + * back to the standard C allocator (malloc/free). + */ + +#ifndef PHP_CEDAR_COMPAT_H +#define PHP_CEDAR_COMPAT_H + +#include +#include +#include + +/* ---- Return codes (NGINX-compatible) --------------------------------- */ +#define PHP_CEDAR_OK 0 +#define PHP_CEDAR_ERROR -1 +#define PHP_CEDAR_DECLINED -5 + +/* ---- Log levels (NGINX-compatible numbering) ------------------------- */ +#define PHP_CEDAR_LOG_EMERG 1 +#define PHP_CEDAR_LOG_ALERT 2 +#define PHP_CEDAR_LOG_CRIT 3 +#define PHP_CEDAR_LOG_ERR 4 +#define PHP_CEDAR_LOG_WARN 5 +#define PHP_CEDAR_LOG_NOTICE 6 +#define PHP_CEDAR_LOG_INFO 7 +#define PHP_CEDAR_LOG_DEBUG 8 + +/* ---- Integer types --------------------------------------------------- */ +typedef intptr_t php_cedar_int_t; +typedef uintptr_t php_cedar_uint_t; +typedef intptr_t php_cedar_flag_t; + +/* ---- String type ----------------------------------------------------- */ +typedef struct { + size_t len; + unsigned char *data; +} php_cedar_str_t; + +#define php_cedar_null_string { 0, NULL } +#define php_cedar_string(s) { sizeof(s) - 1, (unsigned char *) s } + +/* ---- Memory helpers -------------------------------------------------- */ +#define php_cedar_memcmp(s1, s2, n) memcmp((s1), (s2), (n)) +#define php_cedar_memcpy(dst, src, n) memcpy((dst), (src), (n)) +#define php_cedar_memzero(buf, n) memset((buf), 0, (n)) + +/* ---- Log context ----------------------------------------------------- */ +typedef struct { + int level; /* lower bound; messages with a larger level are dropped */ + void *handler; /* reserved: a future hook for routing to PHP's error log */ +} php_cedar_log_t; + +void php_cedar_log_error(int level, php_cedar_log_t *log, + int err, const char *fmt, ...); + +/* ---- Memory pool ----------------------------------------------------- * + * Behaves like NGINX's ngx_pool_t: palloc/pcalloc grow the arena and + * pool_destroy frees everything at once. Backed by a singly-linked + * list of emalloc chunks. + */ +typedef struct php_cedar_pool_chunk_s { + struct php_cedar_pool_chunk_s *next; + void *data; +} php_cedar_pool_chunk_t; + +typedef struct { + php_cedar_pool_chunk_t *chunks; + php_cedar_log_t *log; /* optional; may be NULL */ +} php_cedar_pool_t; + +php_cedar_pool_t *php_cedar_pool_create(php_cedar_log_t *log); +void php_cedar_pool_destroy(php_cedar_pool_t *pool); + +void *php_cedar_palloc(php_cedar_pool_t *pool, size_t size); +void *php_cedar_pcalloc(php_cedar_pool_t *pool, size_t size); + +/* ---- Dynamic array --------------------------------------------------- */ +typedef struct { + void *elts; + php_cedar_uint_t nelts; + size_t size; + php_cedar_uint_t nalloc; + php_cedar_pool_t *pool; +} php_cedar_array_t; + +php_cedar_array_t *php_cedar_array_create(php_cedar_pool_t *pool, + php_cedar_uint_t n, size_t size); +void *php_cedar_array_push(php_cedar_array_t *a); + +#endif /* PHP_CEDAR_COMPAT_H */ From 4a4b9085b654402128bcbd2e9240046a82aa4e4b Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 10:55:50 +0900 Subject: [PATCH 03/19] feat: implement PHP extension with PolicyStore and AuthorizationClient Wire the imported Cedar evaluator into a PHP 8 extension that exposes an AVP-compatible authorization API. Build glue - config.m4 enables --enable-cedar, compiles cedar.c plus the src/cedar/ sources, defines PHP_CEDAR_USE_ZEND_MM so the compatibility layer routes allocations through the Zend Memory Manager, and registers src/cedar/ on the include path - php_cedar.h declares the module entry and version - .gitignore filters out phpize/autotools artifacts and gen_stub legacy output Surface (declared in cedar.stub.php, generated into cedar_arginfo.h) - Cedar\\PolicyStore::__construct(?string \$policyStoreId = null) - auto-generates a 32-char lowercase hex id when none is given - Cedar\\PolicyStore::loadFile(string \$policyId, string \$path): static - Cedar\\PolicyStore::loadString(string \$policyId, string \$cedarText): static - Cedar\\PolicyStore::id(): string - Cedar\\PolicyStore::policyIds(): list - Cedar\\AuthorizationClient::__construct(PolicyStore \$policyStore) - Cedar\\AuthorizationClient::isAuthorized(array \$params): array - Cedar\\AuthorizationClient::isAuthorizedWithToken(array \$params): array - Cedar\\Exception\\PolicyParseException, EvaluationException, ResourceNotFoundException (all extend \\RuntimeException) PolicyStore internals - A php_cedar_pool_t owned by the object holds the parsed php_cedar_policy_set_t bundles - A HashTable maps policyId (zend_string) to its policy set pointer - loadFile / loadString call php_cedar_parse(); parse failures and duplicate policy ids raise PolicyParseException with the offending id in the message - Internal log level is set to 0 so the evaluator stays quiet; error details surface through exceptions instead AuthorizationClient::isAuthorized() golden path - Validates 'policyStoreId' as a string and compares it against the bound store id; mismatch raises ResourceNotFoundException (named after AVP's same-named error) - Extracts {entityType, entityId} for principal/resource and {actionType, actionId} for action and feeds them into a request-scoped php_cedar_eval_ctx_t - Walks every policy_set in the store and combines decisions per Cedar semantics: any matched forbid -> DENY, otherwise any matched permit -> ALLOW, otherwise implicit DENY - Returns the AVP-shaped result array {decision, determiningPolicies, errors}; determiningPolicies lists the policyId of each bundle that produced a contributing decision isAuthorizedWithToken() is stubbed out for this release: it throws RuntimeException pointing callers at isAuthorized() with an externally-verified principal. JWT verification is intentionally left to the caller because the extension does not bundle an identity source (Cognito / OIDC) like AVP does. context.contextMap, entities.entityList, the non-scalar AttributeValue union members (ipaddr / decimal / entityIdentifier / set / record), and transitive parent resolution are deferred as follow-up work. --- .gitignore | 44 ++++ cedar.c | 562 ++++++++++++++++++++++++++++++++++++++++++++++++ cedar.stub.php | 53 +++++ cedar_arginfo.h | 129 +++++++++++ config.m4 | 30 +++ php_cedar.h | 28 +++ 6 files changed, 846 insertions(+) create mode 100644 .gitignore create mode 100644 cedar.c create mode 100644 cedar.stub.php create mode 100644 cedar_arginfo.h create mode 100644 config.m4 create mode 100644 php_cedar.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41e7a99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# phpize / autotools build artifacts +*.dep +*.la +*.lo +*.loT +*.o +.deps/ +.libs/ +.libtool +autom4te.cache/ +build/ +config.h +config.h.in +config.h.in~ +config.log +config.nice +config.status +configure +configure~ +configure.ac +configure.in +libtool +ltmain.sh +Makefile +Makefile.fragments +Makefile.global +Makefile.objects +mkinstalldirs +modules/ +run-tests.php +acinclude.m4 +aclocal.m4 + +# gen_stub.php artifacts +cedar_legacy_arginfo.h + +# test artifacts +tests/**/*.diff +tests/**/*.exp +tests/**/*.log +tests/**/*.out +tests/**/*.php +tests/**/*.sh +tests/**/*.mem diff --git a/cedar.c b/cedar.c new file mode 100644 index 0000000..250a277 --- /dev/null +++ b/cedar.c @@ -0,0 +1,562 @@ +/* + * php-ext-cedar — Local Cedar policy evaluator (AVP-compatible API) + * + * Cedar\PolicyStore : container that holds multiple policies + * Cedar\AuthorizationClient : AVP-compatible evaluation client + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "php.h" +#include "ext/standard/info.h" +#include "ext/random/php_random_csprng.h" +#include "Zend/zend_exceptions.h" +#include "Zend/zend_string.h" +#include "ext/spl/spl_exceptions.h" + +#include "php_cedar.h" + +#include "php_cedar_compat.h" +#include "php_cedar_parser.h" +#include "php_cedar_eval.h" +#include "php_cedar_types.h" + +#include "cedar_arginfo.h" + +/* ---- Class entries -------------------------------------------------- */ +static zend_class_entry *cedar_ce_PolicyStore; +static zend_class_entry *cedar_ce_AuthorizationClient; +static zend_class_entry *cedar_ce_PolicyParseException; +static zend_class_entry *cedar_ce_EvaluationException; +static zend_class_entry *cedar_ce_ResourceNotFoundException; + +/* ============================================================ + * Cedar\PolicyStore + * ============================================================ */ + +typedef struct { + php_cedar_pool_t *pool; + php_cedar_log_t log; + zend_string *id; + HashTable policies; /* policyId (zend_string) => php_cedar_policy_set_t* */ + zend_object std; +} cedar_policy_store_t; + +static inline cedar_policy_store_t * +cedar_policy_store_from_obj(zend_object *obj) +{ + return (cedar_policy_store_t *) + ((char *) obj - XtOffsetOf(cedar_policy_store_t, std)); +} +#define Z_CEDAR_POLICY_STORE_P(zv) cedar_policy_store_from_obj(Z_OBJ_P(zv)) + +static zend_object_handlers cedar_policy_store_handlers; + +static zend_object * +cedar_policy_store_create(zend_class_entry *ce) +{ + cedar_policy_store_t *intern = zend_object_alloc(sizeof(*intern), ce); + + zend_object_std_init(&intern->std, ce); + object_properties_init(&intern->std, ce); + intern->std.handlers = &cedar_policy_store_handlers; + + /* Suppress evaluator-internal logs (level=0 silences every level); + * error details are surfaced via exceptions instead. */ + intern->log.level = 0; + intern->log.handler = NULL; + intern->pool = php_cedar_pool_create(&intern->log); + intern->id = NULL; + zend_hash_init(&intern->policies, 0, NULL, NULL, 0); + + return &intern->std; +} + +static void +cedar_policy_store_free(zend_object *obj) +{ + cedar_policy_store_t *intern = cedar_policy_store_from_obj(obj); + + if (intern->id) { + zend_string_release(intern->id); + intern->id = NULL; + } + zend_hash_destroy(&intern->policies); + if (intern->pool) { + php_cedar_pool_destroy(intern->pool); + intern->pool = NULL; + } + zend_object_std_dtor(&intern->std); +} + +/* Generate a 32-char lowercase hex id from 16 random bytes. */ +static zend_string * +cedar_generate_policy_store_id(void) +{ + unsigned char raw[16]; + char hex[33]; + int i; + static const char *digits = "0123456789abcdef"; + + if (php_random_bytes_silent(raw, sizeof(raw)) == FAILURE) { + /* Fallback (only when the CSPRNG fails) still fills the 16-byte + * buffer, so the output keeps the same 32-char hex shape. */ + static uint64_t seq = 0; + uint64_t t = (uint64_t) time(NULL); + uint64_t s = ++seq; + memcpy(raw, &t, sizeof(t)); + memcpy(raw + sizeof(t), &s, sizeof(s)); + } + for (i = 0; i < 16; i++) { + hex[i * 2] = digits[(raw[i] >> 4) & 0xf]; + hex[i * 2 + 1] = digits[raw[i] & 0xf]; + } + hex[32] = '\0'; + return zend_string_init(hex, 32, 0); +} + +PHP_METHOD(Cedar_PolicyStore, __construct) +{ + cedar_policy_store_t *intern; + zend_string *id = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_STR_OR_NULL(id) + ZEND_PARSE_PARAMETERS_END(); + + intern = Z_CEDAR_POLICY_STORE_P(ZEND_THIS); + if (intern->pool == NULL) { + zend_throw_exception_ex(spl_ce_RuntimeException, 0, + "failed to allocate PolicyStore backing pool"); + return; + } + if (id) { + intern->id = zend_string_copy(id); + } else { + intern->id = cedar_generate_policy_store_id(); + } +} + +/* Shared body for loadFile / loadString. Throws on failure. */ +static void +cedar_policy_store_register_text(cedar_policy_store_t *intern, + zend_string *policy_id, + const char *text, size_t text_len) +{ + php_cedar_str_t src; + php_cedar_policy_set_t *ps; + + if (zend_hash_exists(&intern->policies, policy_id)) { + zend_throw_exception_ex(cedar_ce_PolicyParseException, 0, + "policy id \"%s\" already loaded", ZSTR_VAL(policy_id)); + return; + } + src.data = (unsigned char *) text; + src.len = text_len; + + ps = php_cedar_parse(intern->pool, &intern->log, &src); + if (ps == NULL) { + zend_throw_exception_ex(cedar_ce_PolicyParseException, 0, + "failed to parse cedar policy \"%s\"", ZSTR_VAL(policy_id)); + return; + } + zend_hash_add_ptr(&intern->policies, policy_id, ps); +} + +PHP_METHOD(Cedar_PolicyStore, loadFile) +{ + cedar_policy_store_t *intern; + zend_string *policy_id; + zend_string *path; + php_stream *stream; + zend_string *contents; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(policy_id) + Z_PARAM_PATH_STR(path) + ZEND_PARSE_PARAMETERS_END(); + + intern = Z_CEDAR_POLICY_STORE_P(ZEND_THIS); + + stream = php_stream_open_wrapper(ZSTR_VAL(path), "rb", + 0, NULL); + if (stream == NULL) { + zend_throw_exception_ex(cedar_ce_PolicyParseException, 0, + "failed to open cedar policy file \"%s\"", ZSTR_VAL(path)); + return; + } + contents = php_stream_copy_to_mem(stream, PHP_STREAM_COPY_ALL, 0); + php_stream_close(stream); + if (contents == NULL) { + zend_throw_exception_ex(cedar_ce_PolicyParseException, 0, + "failed to read cedar policy file \"%s\"", ZSTR_VAL(path)); + return; + } + + cedar_policy_store_register_text(intern, policy_id, + ZSTR_VAL(contents), ZSTR_LEN(contents)); + zend_string_release(contents); + + if (EG(exception) == NULL) { + RETURN_OBJ_COPY(Z_OBJ_P(ZEND_THIS)); + } +} + +PHP_METHOD(Cedar_PolicyStore, loadString) +{ + cedar_policy_store_t *intern; + zend_string *policy_id; + zend_string *text; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(policy_id) + Z_PARAM_STR(text) + ZEND_PARSE_PARAMETERS_END(); + + intern = Z_CEDAR_POLICY_STORE_P(ZEND_THIS); + cedar_policy_store_register_text(intern, policy_id, + ZSTR_VAL(text), ZSTR_LEN(text)); + if (EG(exception) == NULL) { + RETURN_OBJ_COPY(Z_OBJ_P(ZEND_THIS)); + } +} + +PHP_METHOD(Cedar_PolicyStore, id) +{ + cedar_policy_store_t *intern; + + ZEND_PARSE_PARAMETERS_NONE(); + intern = Z_CEDAR_POLICY_STORE_P(ZEND_THIS); + if (intern->id) { + RETURN_STR_COPY(intern->id); + } + RETURN_EMPTY_STRING(); +} + +PHP_METHOD(Cedar_PolicyStore, policyIds) +{ + cedar_policy_store_t *intern; + zend_string *key; + + ZEND_PARSE_PARAMETERS_NONE(); + intern = Z_CEDAR_POLICY_STORE_P(ZEND_THIS); + array_init(return_value); + ZEND_HASH_FOREACH_STR_KEY(&intern->policies, key) { + if (key) { + add_next_index_str(return_value, zend_string_copy(key)); + } + } ZEND_HASH_FOREACH_END(); +} + +/* ============================================================ + * Cedar\AuthorizationClient + * ============================================================ */ + +typedef struct { + zval policy_store; /* PolicyStore object held by reference */ + zend_object std; +} cedar_authz_client_t; + +static inline cedar_authz_client_t * +cedar_authz_client_from_obj(zend_object *obj) +{ + return (cedar_authz_client_t *) + ((char *) obj - XtOffsetOf(cedar_authz_client_t, std)); +} +#define Z_CEDAR_AUTHZ_CLIENT_P(zv) cedar_authz_client_from_obj(Z_OBJ_P(zv)) + +static zend_object_handlers cedar_authz_client_handlers; + +static zend_object * +cedar_authz_client_create(zend_class_entry *ce) +{ + cedar_authz_client_t *intern = zend_object_alloc(sizeof(*intern), ce); + zend_object_std_init(&intern->std, ce); + object_properties_init(&intern->std, ce); + intern->std.handlers = &cedar_authz_client_handlers; + ZVAL_UNDEF(&intern->policy_store); + return &intern->std; +} + +static void +cedar_authz_client_free(zend_object *obj) +{ + cedar_authz_client_t *intern = cedar_authz_client_from_obj(obj); + zval_ptr_dtor(&intern->policy_store); + zend_object_std_dtor(&intern->std); +} + +PHP_METHOD(Cedar_AuthorizationClient, __construct) +{ + cedar_authz_client_t *intern; + zval *store; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJECT_OF_CLASS(store, cedar_ce_PolicyStore) + ZEND_PARSE_PARAMETERS_END(); + + intern = Z_CEDAR_AUTHZ_CLIENT_P(ZEND_THIS); + ZVAL_COPY(&intern->policy_store, store); +} + +/* Extract a php_cedar_str_t pair (type, id) from an AVP-style + * EntityIdentifier or ActionIdentifier associative array. */ +static int +cedar_pick_entity_ids(zval *ent, + const char *type_key, size_t type_key_len, + const char *id_key, size_t id_key_len, + php_cedar_str_t *type_out, php_cedar_str_t *id_out) +{ + zval *zt, *zi; + + if (ent == NULL || Z_TYPE_P(ent) != IS_ARRAY) { + return PHP_CEDAR_ERROR; + } + zt = zend_hash_str_find(Z_ARRVAL_P(ent), type_key, type_key_len); + zi = zend_hash_str_find(Z_ARRVAL_P(ent), id_key, id_key_len); + if (!zt || !zi + || Z_TYPE_P(zt) != IS_STRING || Z_TYPE_P(zi) != IS_STRING) { + return PHP_CEDAR_ERROR; + } + type_out->data = (unsigned char *) Z_STRVAL_P(zt); + type_out->len = Z_STRLEN_P(zt); + id_out->data = (unsigned char *) Z_STRVAL_P(zi); + id_out->len = Z_STRLEN_P(zi); + return PHP_CEDAR_OK; +} + +/* Populate return_value with the AVP-compatible response shape. */ +static void +cedar_finalize_response(zval *return_value, + int has_allow, int has_forbid, + zval *determining, zval *errors) +{ + const char *decision = (has_forbid || !has_allow) ? "DENY" : "ALLOW"; + + array_init(return_value); + add_assoc_string(return_value, "decision", decision); + add_assoc_zval(return_value, "determiningPolicies", determining); + add_assoc_zval(return_value, "errors", errors); +} + +/* Evaluate one policy_set and update the aggregated decision state. */ +static void +cedar_eval_one_bundle(zend_string *policy_id, + php_cedar_policy_set_t *ps, + php_cedar_eval_ctx_t *ctx, + php_cedar_log_t *log, + int *has_allow, int *has_forbid, + zval *determining) +{ + php_cedar_decision_detail_t detail; + php_cedar_decision_t d; + + memset(&detail, 0, sizeof(detail)); + d = php_cedar_eval_detail(ps, ctx, log, &detail); + + if (d == PHP_CEDAR_DECISION_ALLOW) { + *has_allow = 1; + } else if (d == PHP_CEDAR_DECISION_DENY && detail.npolicies > 0) { + /* DENY reached because at least one forbid matched. */ + *has_forbid = 1; + } else { + /* Implicit DENY (no permit matched); do not add to determining. */ + return; + } + if (policy_id) { + zval entry; + array_init(&entry); + add_assoc_str(&entry, "policyId", zend_string_copy(policy_id)); + add_next_index_zval(determining, &entry); + } +} + +PHP_METHOD(Cedar_AuthorizationClient, isAuthorized) +{ + cedar_authz_client_t *intern; + cedar_policy_store_t *store; + zval *params; + zval *zid, *zp, *za, *zr; + php_cedar_str_t p_type, p_id, a_type, a_id, r_type, r_id; + php_cedar_pool_t *eval_pool; + php_cedar_log_t eval_log; + php_cedar_eval_ctx_t *eval_ctx; + int has_allow = 0; + int has_forbid = 0; + zval determining, errors; + zend_string *pid_key; + php_cedar_policy_set_t *ps; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY(params) + ZEND_PARSE_PARAMETERS_END(); + + intern = Z_CEDAR_AUTHZ_CLIENT_P(ZEND_THIS); + if (Z_TYPE(intern->policy_store) != IS_OBJECT) { + zend_throw_exception_ex(cedar_ce_EvaluationException, 0, + "AuthorizationClient is not bound to a PolicyStore"); + return; + } + store = Z_CEDAR_POLICY_STORE_P(&intern->policy_store); + + /* policyStoreId is a required AVP key; reject non-string values. */ + zid = zend_hash_str_find(Z_ARRVAL_P(params), + "policyStoreId", sizeof("policyStoreId") - 1); + if (!zid || Z_TYPE_P(zid) != IS_STRING) { + zend_throw_error(NULL, + "isAuthorized(): 'policyStoreId' (string) is required"); + return; + } + if (!store->id + || Z_STRLEN_P(zid) != ZSTR_LEN(store->id) + || memcmp(Z_STRVAL_P(zid), ZSTR_VAL(store->id), + ZSTR_LEN(store->id)) != 0) { + zend_throw_exception_ex(cedar_ce_ResourceNotFoundException, 0, + "policyStoreId '%s' does not match the bound PolicyStore", + Z_STRVAL_P(zid)); + return; + } + + zp = zend_hash_str_find(Z_ARRVAL_P(params), + "principal", sizeof("principal") - 1); + za = zend_hash_str_find(Z_ARRVAL_P(params), + "action", sizeof("action") - 1); + zr = zend_hash_str_find(Z_ARRVAL_P(params), + "resource", sizeof("resource") - 1); + + if (cedar_pick_entity_ids(zp, "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &p_type, &p_id) != PHP_CEDAR_OK + || cedar_pick_entity_ids(za, "actionType", sizeof("actionType") - 1, + "actionId", sizeof("actionId") - 1, + &a_type, &a_id) != PHP_CEDAR_OK + || cedar_pick_entity_ids(zr, "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &r_type, &r_id) != PHP_CEDAR_OK) { + zend_throw_error(NULL, + "isAuthorized(): principal/resource must be " + "{entityType, entityId}, action must be {actionType, actionId}"); + return; + } + + /* Request-scoped evaluation pool and context. */ + memset(&eval_log, 0, sizeof(eval_log)); + eval_log.level = 0; + eval_pool = php_cedar_pool_create(&eval_log); + if (!eval_pool) { + zend_throw_exception_ex(cedar_ce_EvaluationException, 0, + "failed to allocate evaluation pool"); + return; + } + eval_ctx = php_cedar_eval_ctx_create(eval_pool); + if (!eval_ctx) { + php_cedar_pool_destroy(eval_pool); + zend_throw_exception_ex(cedar_ce_EvaluationException, 0, + "failed to create evaluation context"); + return; + } + + php_cedar_eval_ctx_set_principal(eval_ctx, &p_type, &p_id); + php_cedar_eval_ctx_set_action(eval_ctx, &a_type, &a_id); + php_cedar_eval_ctx_set_resource(eval_ctx, &r_type, &r_id); + + /* TODO (M4 follow-up): context.contextMap, entities.entityList, + * AttributeValue Union beyond scalars, transitive parent resolution. */ + + array_init(&determining); + array_init(&errors); + + /* Evaluate every policy_set in the store and combine the results. */ + ZEND_HASH_FOREACH_STR_KEY_PTR(&store->policies, pid_key, ps) { + cedar_eval_one_bundle(pid_key, ps, eval_ctx, &eval_log, + &has_allow, &has_forbid, &determining); + } ZEND_HASH_FOREACH_END(); + + php_cedar_pool_destroy(eval_pool); + + cedar_finalize_response(return_value, has_allow, has_forbid, + &determining, &errors); +} + +/* isAuthorizedWithToken defers JWT verification to the caller, so the + * extension itself does not implement it yet. Surface a clear runtime + * error pointing users to isAuthorized() with an extracted principal. */ +PHP_METHOD(Cedar_AuthorizationClient, isAuthorizedWithToken) +{ + zval *params; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY(params) + ZEND_PARSE_PARAMETERS_END(); + (void) params; + + zend_throw_exception_ex(spl_ce_RuntimeException, 0, + "isAuthorizedWithToken() is not implemented yet; " + "verify JWT externally and call isAuthorized() with the extracted principal"); +} + +/* ============================================================ + * Module initialization + * ============================================================ */ + +PHP_MINIT_FUNCTION(cedar) +{ + /* PolicyStore */ + cedar_ce_PolicyStore = register_class_Cedar_PolicyStore(); + cedar_ce_PolicyStore->create_object = cedar_policy_store_create; + memcpy(&cedar_policy_store_handlers, zend_get_std_object_handlers(), + sizeof(zend_object_handlers)); + cedar_policy_store_handlers.offset = XtOffsetOf(cedar_policy_store_t, std); + cedar_policy_store_handlers.free_obj = cedar_policy_store_free; + cedar_policy_store_handlers.clone_obj = NULL; + + /* AuthorizationClient */ + cedar_ce_AuthorizationClient = register_class_Cedar_AuthorizationClient(); + cedar_ce_AuthorizationClient->create_object = cedar_authz_client_create; + memcpy(&cedar_authz_client_handlers, zend_get_std_object_handlers(), + sizeof(zend_object_handlers)); + cedar_authz_client_handlers.offset = XtOffsetOf(cedar_authz_client_t, std); + cedar_authz_client_handlers.free_obj = cedar_authz_client_free; + cedar_authz_client_handlers.clone_obj = NULL; + + /* Exceptions (subclass of \RuntimeException) */ + cedar_ce_PolicyParseException = + register_class_Cedar_Exception_PolicyParseException( + spl_ce_RuntimeException); + cedar_ce_EvaluationException = + register_class_Cedar_Exception_EvaluationException( + spl_ce_RuntimeException); + cedar_ce_ResourceNotFoundException = + register_class_Cedar_Exception_ResourceNotFoundException( + spl_ce_RuntimeException); + + return SUCCESS; +} + +PHP_MINFO_FUNCTION(cedar) +{ + php_info_print_table_start(); + php_info_print_table_header(2, "cedar support", "enabled"); + php_info_print_table_row(2, "version", PHP_CEDAR_VERSION); + php_info_print_table_end(); +} + +zend_module_entry cedar_module_entry = { + STANDARD_MODULE_HEADER, + "cedar", + NULL, /* functions */ + PHP_MINIT(cedar), + NULL, /* MSHUTDOWN */ + NULL, /* RINIT */ + NULL, /* RSHUTDOWN */ + PHP_MINFO(cedar), + PHP_CEDAR_VERSION, + STANDARD_MODULE_PROPERTIES +}; + +#ifdef COMPILE_DL_CEDAR +ZEND_GET_MODULE(cedar) +#endif diff --git a/cedar.stub.php b/cedar.stub.php new file mode 100644 index 0000000..4863f21 --- /dev/null +++ b/cedar.stub.php @@ -0,0 +1,53 @@ + */ + public function policyIds(): array {} + } + + /** + * Local evaluation client compatible with AVP's + * Aws\VerifiedPermissions\VerifiedPermissionsClient. + */ + final class AuthorizationClient + { + public function __construct(PolicyStore $policyStore) {} + + public function isAuthorized(array $params): array {} + + public function isAuthorizedWithToken(array $params): array {} + } +} + +namespace Cedar\Exception +{ + class PolicyParseException extends \RuntimeException {} + + class EvaluationException extends \RuntimeException {} + + class ResourceNotFoundException extends \RuntimeException {} +} diff --git a/cedar_arginfo.h b/cedar_arginfo.h new file mode 100644 index 0000000..5c7d58f --- /dev/null +++ b/cedar_arginfo.h @@ -0,0 +1,129 @@ +/* This is a generated file, edit the .stub.php file instead. + * Stub hash: c0a768b1135221393bb19d04c3001606665fb33e */ + +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Cedar_PolicyStore___construct, 0, 0, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, policyStoreId, IS_STRING, 1, "null") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Cedar_PolicyStore_loadFile, 0, 2, IS_STATIC, 0) + ZEND_ARG_TYPE_INFO(0, policyId, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, path, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Cedar_PolicyStore_loadString, 0, 2, IS_STATIC, 0) + ZEND_ARG_TYPE_INFO(0, policyId, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, cedarText, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Cedar_PolicyStore_id, 0, 0, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Cedar_PolicyStore_policyIds, 0, 0, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Cedar_AuthorizationClient___construct, 0, 0, 1) + ZEND_ARG_OBJ_INFO(0, policyStore, Cedar\\PolicyStore, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Cedar_AuthorizationClient_isAuthorized, 0, 1, IS_ARRAY, 0) + ZEND_ARG_TYPE_INFO(0, params, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +#define arginfo_class_Cedar_AuthorizationClient_isAuthorizedWithToken arginfo_class_Cedar_AuthorizationClient_isAuthorized + +ZEND_METHOD(Cedar_PolicyStore, __construct); +ZEND_METHOD(Cedar_PolicyStore, loadFile); +ZEND_METHOD(Cedar_PolicyStore, loadString); +ZEND_METHOD(Cedar_PolicyStore, id); +ZEND_METHOD(Cedar_PolicyStore, policyIds); +ZEND_METHOD(Cedar_AuthorizationClient, __construct); +ZEND_METHOD(Cedar_AuthorizationClient, isAuthorized); +ZEND_METHOD(Cedar_AuthorizationClient, isAuthorizedWithToken); + +static const zend_function_entry class_Cedar_PolicyStore_methods[] = { + ZEND_ME(Cedar_PolicyStore, __construct, arginfo_class_Cedar_PolicyStore___construct, ZEND_ACC_PUBLIC) + ZEND_ME(Cedar_PolicyStore, loadFile, arginfo_class_Cedar_PolicyStore_loadFile, ZEND_ACC_PUBLIC) + ZEND_ME(Cedar_PolicyStore, loadString, arginfo_class_Cedar_PolicyStore_loadString, ZEND_ACC_PUBLIC) + ZEND_ME(Cedar_PolicyStore, id, arginfo_class_Cedar_PolicyStore_id, ZEND_ACC_PUBLIC) + ZEND_ME(Cedar_PolicyStore, policyIds, arginfo_class_Cedar_PolicyStore_policyIds, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + +static const zend_function_entry class_Cedar_AuthorizationClient_methods[] = { + ZEND_ME(Cedar_AuthorizationClient, __construct, arginfo_class_Cedar_AuthorizationClient___construct, ZEND_ACC_PUBLIC) + ZEND_ME(Cedar_AuthorizationClient, isAuthorized, arginfo_class_Cedar_AuthorizationClient_isAuthorized, ZEND_ACC_PUBLIC) + ZEND_ME(Cedar_AuthorizationClient, isAuthorizedWithToken, arginfo_class_Cedar_AuthorizationClient_isAuthorizedWithToken, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + +static zend_class_entry *register_class_Cedar_PolicyStore(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Cedar", "PolicyStore", class_Cedar_PolicyStore_methods); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); +#else + class_entry = zend_register_internal_class_ex(&ce, NULL); + class_entry->ce_flags |= ZEND_ACC_FINAL; +#endif + + return class_entry; +} + +static zend_class_entry *register_class_Cedar_AuthorizationClient(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Cedar", "AuthorizationClient", class_Cedar_AuthorizationClient_methods); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); +#else + class_entry = zend_register_internal_class_ex(&ce, NULL); + class_entry->ce_flags |= ZEND_ACC_FINAL; +#endif + + return class_entry; +} + +static zend_class_entry *register_class_Cedar_Exception_PolicyParseException(zend_class_entry *class_entry_RuntimeException) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Cedar\\Exception", "PolicyParseException", NULL); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, class_entry_RuntimeException, 0); +#else + class_entry = zend_register_internal_class_ex(&ce, class_entry_RuntimeException); +#endif + + return class_entry; +} + +static zend_class_entry *register_class_Cedar_Exception_EvaluationException(zend_class_entry *class_entry_RuntimeException) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Cedar\\Exception", "EvaluationException", NULL); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, class_entry_RuntimeException, 0); +#else + class_entry = zend_register_internal_class_ex(&ce, class_entry_RuntimeException); +#endif + + return class_entry; +} + +static zend_class_entry *register_class_Cedar_Exception_ResourceNotFoundException(zend_class_entry *class_entry_RuntimeException) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Cedar\\Exception", "ResourceNotFoundException", NULL); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, class_entry_RuntimeException, 0); +#else + class_entry = zend_register_internal_class_ex(&ce, class_entry_RuntimeException); +#endif + + return class_entry; +} diff --git a/config.m4 b/config.m4 new file mode 100644 index 0000000..707114d --- /dev/null +++ b/config.m4 @@ -0,0 +1,30 @@ +dnl config.m4 for extension cedar + +PHP_ARG_ENABLE([cedar], + [whether to enable cedar support], + [AS_HELP_STRING([--enable-cedar], + [Enable cedar (local Cedar evaluator) support])]) + +if test "$PHP_CEDAR" != "no"; then + + AC_DEFINE(HAVE_CEDAR, 1, [ Have cedar support ]) + AC_DEFINE(PHP_CEDAR_USE_ZEND_MM, 1, + [ Use Zend Memory Manager for cedar evaluator allocations ]) + + CEDAR_SOURCES="cedar.c \ + src/cedar/php_cedar_compat.c \ + src/cedar/php_cedar_lexer.c \ + src/cedar/php_cedar_parser.c \ + src/cedar/php_cedar_expr.c \ + src/cedar/php_cedar_eval.c" + + PHP_NEW_EXTENSION(cedar, $CEDAR_SOURCES, $ext_shared,, + [-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1]) + + PHP_ADD_BUILD_DIR([$ext_builddir/src/cedar], 1) + PHP_ADD_INCLUDE([$ext_srcdir/src/cedar]) + + ifdef([PHP_INSTALL_HEADERS], [ + PHP_INSTALL_HEADERS([ext/cedar], [php_cedar.h]) + ]) +fi diff --git a/php_cedar.h b/php_cedar.h new file mode 100644 index 0000000..417c689 --- /dev/null +++ b/php_cedar.h @@ -0,0 +1,28 @@ +/* + * php-ext-cedar — Local Cedar policy evaluator (AVP-compatible API) + */ + +#ifndef PHP_CEDAR_H +#define PHP_CEDAR_H + +#include "php.h" + +#define PHP_CEDAR_VERSION "0.1.0-dev" +#define PHP_CEDAR_NS "Cedar" + +extern zend_module_entry cedar_module_entry; +#define phpext_cedar_ptr &cedar_module_entry + +#ifdef PHP_WIN32 +# define PHP_CEDAR_API __declspec(dllexport) +#elif defined(__GNUC__) && __GNUC__ >= 4 +# define PHP_CEDAR_API __attribute__((visibility("default"))) +#else +# define PHP_CEDAR_API +#endif + +#ifdef ZTS +# include "TSRM.h" +#endif + +#endif /* PHP_CEDAR_H */ From d03abdfc1fdb6e4835fe9d385f35d08e2cad2864 Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 10:56:05 +0900 Subject: [PATCH 04/19] test: cover PolicyStore and AuthorizationClient behavior with .phpt Eleven phpt cases (make test reports 11/11 PASS) split into two groups. PolicyStore (tests 001-005) - 001: extension loads, the expected classes exist, and the three Cedar\\Exception\\* classes inherit \\RuntimeException - 002: id() returns a 32-char hex when no id is given, returns the supplied string verbatim when one is given, and auto-generated ids differ across instances - 003: loadString registers policies, returns $this for fluent chaining, and policyIds() reflects every registered id in order - 004: parse failure raises PolicyParseException with the offending id in the message; loading the same policy id twice raises the same exception - 005: loadFile reads a cedar policy file via php_stream and registers it under the supplied id AuthorizationClient (tests 006-011) - 006: a single permit policy yields decision=ALLOW and lists the matched policyId in determiningPolicies - 007: a forbid policy yields decision=DENY and lists the matched policyId in determiningPolicies (i.e. forbid, not implicit deny) - 008: an empty store gives the implicit DENY with an empty determiningPolicies array - 009: a policyStoreId that does not match the bound PolicyStore::id raises ResourceNotFoundException; omitting the key raises Error - 010: isAuthorizedWithToken throws RuntimeException for now to signal the unimplemented path - 011: with both permit and forbid loaded, forbid wins for a matching resource and permit wins for a non-matching resource, confirming the cross-bundle combination logic --- tests/001-extension-loaded.phpt | 23 +++++++++ tests/002-policy-store-id.phpt | 23 +++++++++ tests/003-policy-store-load.phpt | 30 +++++++++++ tests/004-policy-store-parse-error.phpt | 25 +++++++++ tests/005-policy-store-load-file.phpt | 20 ++++++++ tests/006-isauthorized-allow.phpt | 34 +++++++++++++ tests/007-isauthorized-forbid.phpt | 34 +++++++++++++ tests/008-isauthorized-default-deny.phpt | 28 ++++++++++ tests/009-isauthorized-mismatch.phpt | 34 +++++++++++++ .../010-isauthorized-with-token-not-impl.phpt | 22 ++++++++ tests/011-isauthorized-multi-policy.phpt | 31 +++++++++++ ...ized-determining-policies-forbid-only.phpt | 51 +++++++++++++++++++ 12 files changed, 355 insertions(+) create mode 100644 tests/001-extension-loaded.phpt create mode 100644 tests/002-policy-store-id.phpt create mode 100644 tests/003-policy-store-load.phpt create mode 100644 tests/004-policy-store-parse-error.phpt create mode 100644 tests/005-policy-store-load-file.phpt create mode 100644 tests/006-isauthorized-allow.phpt create mode 100644 tests/007-isauthorized-forbid.phpt create mode 100644 tests/008-isauthorized-default-deny.phpt create mode 100644 tests/009-isauthorized-mismatch.phpt create mode 100644 tests/010-isauthorized-with-token-not-impl.phpt create mode 100644 tests/011-isauthorized-multi-policy.phpt create mode 100644 tests/024-isauthorized-determining-policies-forbid-only.phpt diff --git a/tests/001-extension-loaded.phpt b/tests/001-extension-loaded.phpt new file mode 100644 index 0000000..7d72dde --- /dev/null +++ b/tests/001-extension-loaded.phpt @@ -0,0 +1,23 @@ +--TEST-- +cedar extension is loaded and exposes the expected classes +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/tests/002-policy-store-id.phpt b/tests/002-policy-store-id.phpt new file mode 100644 index 0000000..b7234e7 --- /dev/null +++ b/tests/002-policy-store-id.phpt @@ -0,0 +1,23 @@ +--TEST-- +PolicyStore: id is auto-generated, or used as-is when explicitly supplied +--SKIPIF-- + +--FILE-- +id(); +var_dump(is_string($id)); +var_dump(strlen($id)); + +$explicit = new Cedar\PolicyStore("my-policy-store"); +var_dump($explicit->id()); + +// auto-generated ids must differ between instances +$another = new Cedar\PolicyStore(); +var_dump($id !== $another->id()); +?> +--EXPECT-- +bool(true) +int(32) +string(15) "my-policy-store" +bool(true) diff --git a/tests/003-policy-store-load.phpt b/tests/003-policy-store-load.phpt new file mode 100644 index 0000000..7a7ab45 --- /dev/null +++ b/tests/003-policy-store-load.phpt @@ -0,0 +1,30 @@ +--TEST-- +PolicyStore: loadString / policyIds with fluent interface +--SKIPIF-- + +--FILE-- +policyIds()); + +$ret = $store->loadString("admin", "permit(principal, action, resource);"); +var_dump($ret === $store); +var_dump($store->policyIds()); + +$store->loadString("reader", 'permit(principal, action == Action::"read", resource);'); +var_dump($store->policyIds()); +?> +--EXPECT-- +array(0) { +} +bool(true) +array(1) { + [0]=> + string(5) "admin" +} +array(2) { + [0]=> + string(5) "admin" + [1]=> + string(6) "reader" +} diff --git a/tests/004-policy-store-parse-error.phpt b/tests/004-policy-store-parse-error.phpt new file mode 100644 index 0000000..bccbcec --- /dev/null +++ b/tests/004-policy-store-parse-error.phpt @@ -0,0 +1,25 @@ +--TEST-- +PolicyStore: parse failure and duplicate policy id raise PolicyParseException +--SKIPIF-- + +--FILE-- +loadString("broken", "this is not a cedar policy at all"); +} catch (Cedar\Exception\PolicyParseException $e) { + echo "caught: ", $e->getMessage(), PHP_EOL; +} + +// duplicate policyId +$store->loadString("dup", "permit(principal, action, resource);"); +try { + $store->loadString("dup", "permit(principal, action, resource);"); +} catch (Cedar\Exception\PolicyParseException $e) { + echo "caught: ", $e->getMessage(), PHP_EOL; +} +?> +--EXPECTF-- +caught: failed to parse cedar policy "broken" +caught: policy id "dup" already loaded diff --git a/tests/005-policy-store-load-file.phpt b/tests/005-policy-store-load-file.phpt new file mode 100644 index 0000000..592121a --- /dev/null +++ b/tests/005-policy-store-load-file.phpt @@ -0,0 +1,20 @@ +--TEST-- +PolicyStore: loadFile reads a policy from disk +--SKIPIF-- + +--FILE-- +loadFile("p1", $path); +var_dump($store->policyIds()); + +unlink($path); +?> +--EXPECT-- +array(1) { + [0]=> + string(2) "p1" +} diff --git a/tests/006-isauthorized-allow.phpt b/tests/006-isauthorized-allow.phpt new file mode 100644 index 0000000..e38e0dd --- /dev/null +++ b/tests/006-isauthorized-allow.phpt @@ -0,0 +1,34 @@ +--TEST-- +AuthorizationClient::isAuthorized: a permit policy yields ALLOW +--SKIPIF-- + +--FILE-- +loadString("p1", "permit(principal, action, resource);"); + +$client = new Cedar\AuthorizationClient($store); +$res = $client->isAuthorized([ + "policyStoreId" => "my-store", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +var_dump($res); +?> +--EXPECT-- +array(3) { + ["decision"]=> + string(5) "ALLOW" + ["determiningPolicies"]=> + array(1) { + [0]=> + array(1) { + ["policyId"]=> + string(2) "p1" + } + } + ["errors"]=> + array(0) { + } +} diff --git a/tests/007-isauthorized-forbid.phpt b/tests/007-isauthorized-forbid.phpt new file mode 100644 index 0000000..fad19c1 --- /dev/null +++ b/tests/007-isauthorized-forbid.phpt @@ -0,0 +1,34 @@ +--TEST-- +AuthorizationClient::isAuthorized: a forbid policy yields DENY with the matched policy in determiningPolicies +--SKIPIF-- + +--FILE-- +loadString("p1", "forbid(principal, action, resource);"); + +$client = new Cedar\AuthorizationClient($store); +$res = $client->isAuthorized([ + "policyStoreId" => "my-store", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +var_dump($res); +?> +--EXPECT-- +array(3) { + ["decision"]=> + string(4) "DENY" + ["determiningPolicies"]=> + array(1) { + [0]=> + array(1) { + ["policyId"]=> + string(2) "p1" + } + } + ["errors"]=> + array(0) { + } +} diff --git a/tests/008-isauthorized-default-deny.phpt b/tests/008-isauthorized-default-deny.phpt new file mode 100644 index 0000000..f48003b --- /dev/null +++ b/tests/008-isauthorized-default-deny.phpt @@ -0,0 +1,28 @@ +--TEST-- +AuthorizationClient::isAuthorized: an empty store falls back to implicit DENY with empty determiningPolicies +--SKIPIF-- + +--FILE-- +isAuthorized([ + "policyStoreId" => "empty-store", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +var_dump($res); +?> +--EXPECT-- +array(3) { + ["decision"]=> + string(4) "DENY" + ["determiningPolicies"]=> + array(0) { + } + ["errors"]=> + array(0) { + } +} diff --git a/tests/009-isauthorized-mismatch.phpt b/tests/009-isauthorized-mismatch.phpt new file mode 100644 index 0000000..696c941 --- /dev/null +++ b/tests/009-isauthorized-mismatch.phpt @@ -0,0 +1,34 @@ +--TEST-- +AuthorizationClient::isAuthorized: mismatched policyStoreId throws ResourceNotFoundException; missing key throws Error +--SKIPIF-- + +--FILE-- +isAuthorized([ + "policyStoreId" => "wrong-id", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + ]); +} catch (Cedar\Exception\ResourceNotFoundException $e) { + echo $e->getMessage(), PHP_EOL; +} + +// AVP requires policyStoreId; omitting it must fail +try { + $client->isAuthorized([ + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + ]); +} catch (Error $e) { + echo $e->getMessage(), PHP_EOL; +} +?> +--EXPECT-- +policyStoreId 'wrong-id' does not match the bound PolicyStore +isAuthorized(): 'policyStoreId' (string) is required diff --git a/tests/010-isauthorized-with-token-not-impl.phpt b/tests/010-isauthorized-with-token-not-impl.phpt new file mode 100644 index 0000000..16403d6 --- /dev/null +++ b/tests/010-isauthorized-with-token-not-impl.phpt @@ -0,0 +1,22 @@ +--TEST-- +AuthorizationClient::isAuthorizedWithToken: not yet implemented in the current release +--SKIPIF-- + +--FILE-- +isAuthorizedWithToken([ + "policyStoreId" => $store->id(), + "identityToken" => "eyJ...", + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + ]); +} catch (RuntimeException $e) { + echo $e->getMessage(), PHP_EOL; +} +?> +--EXPECTF-- +isAuthorizedWithToken() is not implemented yet;%a diff --git a/tests/011-isauthorized-multi-policy.phpt b/tests/011-isauthorized-multi-policy.phpt new file mode 100644 index 0000000..ee3ca72 --- /dev/null +++ b/tests/011-isauthorized-multi-policy.phpt @@ -0,0 +1,31 @@ +--TEST-- +AuthorizationClient::isAuthorized: forbid overrides permit across multiple policies +--SKIPIF-- + +--FILE-- +loadString("allow-all", "permit(principal, action, resource);") + ->loadString("forbid-doc", 'forbid(principal, action, resource == Doc::"doc1");'); + +$client = new Cedar\AuthorizationClient($store); +$res = $client->isAuthorized([ + "policyStoreId" => "multi", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +echo $res["decision"], PHP_EOL; + +// a resource the forbid does not match falls back to ALLOW +$res2 = $client->isAuthorized([ + "policyStoreId" => "multi", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc2"], +]); +echo $res2["decision"], PHP_EOL; +?> +--EXPECT-- +DENY +ALLOW diff --git a/tests/024-isauthorized-determining-policies-forbid-only.phpt b/tests/024-isauthorized-determining-policies-forbid-only.phpt new file mode 100644 index 0000000..4c8c1f2 --- /dev/null +++ b/tests/024-isauthorized-determining-policies-forbid-only.phpt @@ -0,0 +1,51 @@ +--TEST-- +AuthorizationClient::isAuthorized: determiningPolicies lists only the forbid when forbid overrides permit (AVP semantics) +--SKIPIF-- + +--FILE-- +loadString("allow-all", "permit(principal, action, resource);") + ->loadString("forbid-doc1", 'forbid(principal, action, resource == Doc::"doc1");') + ->loadString("forbid-doc2", 'forbid(principal, action, resource == Doc::"doc1");'); + +$client = new Cedar\AuthorizationClient($store); + +// (1) forbid wins: determiningPolicies must contain only the forbids, +// never the matched permit. +$res = $client->isAuthorized([ + "policyStoreId" => "multi", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +$ids = array_map(fn($p) => $p["policyId"], $res["determiningPolicies"]); +sort($ids); +echo "1 decision: ", $res["decision"], PHP_EOL; +echo "1 determining: ", implode(",", $ids), PHP_EOL; + +// (2) No forbid matches: determiningPolicies lists the permit only. +$res = $client->isAuthorized([ + "policyStoreId" => "multi", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc2"], +]); +$ids = array_map(fn($p) => $p["policyId"], $res["determiningPolicies"]); +echo "2 decision: ", $res["decision"], PHP_EOL; +echo "2 determining: ", implode(",", $ids), PHP_EOL; +?> +--EXPECT-- +1 decision: DENY +1 determining: forbid-doc1,forbid-doc2 +2 decision: ALLOW +2 determining: allow-all From dfc21ee0cb7a1a5fe3fce8af536e130e84bee8eb Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 11:28:37 +0900 Subject: [PATCH 05/19] feat: support context.contextMap, entities.entityList, and non-scalar AttributeValue Extend AuthorizationClient::isAuthorized() so the optional 'context' and 'entities' inputs from the AVP request shape are wired into the evaluator alongside the previously-handled principal / action / resource scalars. AttributeValue Union (single-key associative array) now maps to the matching php_cedar_eval_ctx_add_*_attr* helpers for every kind the evaluator supports: - string -> add_*_attr - long -> add_*_attr_long (accepts PHP long or float, cast to int64) - boolean -> add_*_attr_bool - ipaddr -> add_*_attr_ip - decimal -> add_*_attr_decimal - entityIdentifier -> add_*_attr_entity (uses {entityType, entityId}) - set -> add_*_attr_set + recursive set_add_* - record -> add_*_attr_record + recursive record_add_* set and record nest arbitrarily via the mutually-recursive cedar_apply_set_element / cedar_apply_record_attr helpers. The upstream evaluator does not support 'datetime' / 'duration', so those Union kinds (and any malformed or empty AttributeValue) are surfaced through the AVP-shaped errors[] array as {errorDescription: 'unsupported or malformed AttributeValue for ...'} rather than aborting the request. entities.entityList walks each entry and: - Matches the identifier against the request principal / resource; when it matches, the entry's attributes feed add_principal_attr_* / add_resource_attr_*. - Forwards parents to add_principal_parent / add_resource_parent. Per Cedar semantics, callers are expected to flatten the parent closure themselves (same contract as AVP). - Entries that match neither the principal nor the resource are silently skipped on attributes; their parents have no anchor in the eval_ctx shape and are skipped too. context.contextMap iterates {name => AttributeValue} entries and funnels each through the same top-level dispatcher with CEDAR_TARGET_CONTEXT. --- cedar.c | 495 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 492 insertions(+), 3 deletions(-) diff --git a/cedar.c b/cedar.c index 250a277..be99dba 100644 --- a/cedar.c +++ b/cedar.c @@ -328,6 +328,491 @@ cedar_pick_entity_ids(zval *ent, return PHP_CEDAR_OK; } +/* ============================================================ + * AttributeValue (AVP Union) -> php_cedar evaluator helpers + * ============================================================ */ + +typedef enum { + CEDAR_TARGET_PRINCIPAL, + CEDAR_TARGET_ACTION, + CEDAR_TARGET_RESOURCE, + CEDAR_TARGET_CONTEXT +} cedar_attr_target_t; + +/* AVP's AttributeValue is a one-of union encoded as a single-key + * associative array, e.g. ['string' => 'x'] or ['set' => [...]]. + * Resolve that wrapper and return the kind string + inner zval. */ +static int +cedar_resolve_attr_union(zval *attr_val, + zend_string **kind_out, zval **inner_out) +{ + HashTable *ht; + zend_string *key; + zval *val; + + if (attr_val == NULL || Z_TYPE_P(attr_val) != IS_ARRAY) { + return PHP_CEDAR_ERROR; + } + ht = Z_ARRVAL_P(attr_val); + if (zend_hash_num_elements(ht) != 1) { + return PHP_CEDAR_ERROR; + } + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + if (!key) { + return PHP_CEDAR_ERROR; + } + *kind_out = key; + *inner_out = val; + return PHP_CEDAR_OK; + } ZEND_HASH_FOREACH_END(); + return PHP_CEDAR_ERROR; +} + +/* Build a php_cedar_str_t view over a zval string (no copy). */ +static int +cedar_zval_to_cedar_str(zval *zv, php_cedar_str_t *out) +{ + if (Z_TYPE_P(zv) != IS_STRING) { + return PHP_CEDAR_ERROR; + } + out->data = (unsigned char *) Z_STRVAL_P(zv); + out->len = Z_STRLEN_P(zv); + return PHP_CEDAR_OK; +} + +/* Forward declarations for the mutually recursive helpers. */ +static int cedar_apply_top_attr(php_cedar_eval_ctx_t *ctx, + cedar_attr_target_t tgt, + php_cedar_str_t *name, zval *attr_val); +static int cedar_apply_record_attr(php_cedar_record_t *rec, + php_cedar_str_t *name, zval *attr_val); +static int cedar_apply_set_element(php_cedar_set_t *set, zval *attr_val); + +/* Populate a record from {key => AttributeValue, ...}. */ +static int +cedar_apply_record_children(php_cedar_record_t *rec, zval *inner) +{ + HashTable *ht; + zend_string *key; + zval *val; + + if (Z_TYPE_P(inner) != IS_ARRAY) { + return PHP_CEDAR_ERROR; + } + ht = Z_ARRVAL_P(inner); + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + php_cedar_str_t name; + if (!key) { + return PHP_CEDAR_ERROR; + } + name.data = (unsigned char *) ZSTR_VAL(key); + name.len = ZSTR_LEN(key); + if (cedar_apply_record_attr(rec, &name, val) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + } ZEND_HASH_FOREACH_END(); + return PHP_CEDAR_OK; +} + +/* Populate a set from [AttributeValue, ...]. */ +static int +cedar_apply_set_children(php_cedar_set_t *set, zval *inner) +{ + HashTable *ht; + zval *val; + + if (Z_TYPE_P(inner) != IS_ARRAY) { + return PHP_CEDAR_ERROR; + } + ht = Z_ARRVAL_P(inner); + ZEND_HASH_FOREACH_VAL(ht, val) { + if (cedar_apply_set_element(set, val) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + } ZEND_HASH_FOREACH_END(); + return PHP_CEDAR_OK; +} + +/* Apply an AttributeValue as a top-level eval_ctx attribute. */ +static int +cedar_apply_top_attr(php_cedar_eval_ctx_t *ctx, cedar_attr_target_t tgt, + php_cedar_str_t *name, zval *attr_val) +{ + zend_string *kind; + zval *inner; + php_cedar_str_t v; + + if (cedar_resolve_attr_union(attr_val, &kind, &inner) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + + if (zend_string_equals_literal(kind, "string")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: return php_cedar_eval_ctx_add_principal_attr(ctx, name, &v); + case CEDAR_TARGET_ACTION: return php_cedar_eval_ctx_add_action_attr (ctx, name, &v); + case CEDAR_TARGET_RESOURCE: return php_cedar_eval_ctx_add_resource_attr (ctx, name, &v); + case CEDAR_TARGET_CONTEXT: return php_cedar_eval_ctx_add_context_attr (ctx, name, &v); + } + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "long")) { + int64_t lv; + if (Z_TYPE_P(inner) != IS_LONG) return PHP_CEDAR_ERROR; + lv = (int64_t) Z_LVAL_P(inner); + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: return php_cedar_eval_ctx_add_principal_attr_long(ctx, name, lv); + case CEDAR_TARGET_ACTION: return php_cedar_eval_ctx_add_action_attr_long (ctx, name, lv); + case CEDAR_TARGET_RESOURCE: return php_cedar_eval_ctx_add_resource_attr_long (ctx, name, lv); + case CEDAR_TARGET_CONTEXT: return php_cedar_eval_ctx_add_context_attr_long (ctx, name, lv); + } + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "boolean")) { + php_cedar_flag_t b; + if (Z_TYPE_P(inner) != IS_TRUE && Z_TYPE_P(inner) != IS_FALSE) { + return PHP_CEDAR_ERROR; + } + b = (Z_TYPE_P(inner) == IS_TRUE) ? 1 : 0; + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: return php_cedar_eval_ctx_add_principal_attr_bool(ctx, name, b); + case CEDAR_TARGET_ACTION: return php_cedar_eval_ctx_add_action_attr_bool (ctx, name, b); + case CEDAR_TARGET_RESOURCE: return php_cedar_eval_ctx_add_resource_attr_bool (ctx, name, b); + case CEDAR_TARGET_CONTEXT: return php_cedar_eval_ctx_add_context_attr_bool (ctx, name, b); + } + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "ipaddr")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: return php_cedar_eval_ctx_add_principal_attr_ip(ctx, name, &v); + case CEDAR_TARGET_ACTION: return php_cedar_eval_ctx_add_action_attr_ip (ctx, name, &v); + case CEDAR_TARGET_RESOURCE: return php_cedar_eval_ctx_add_resource_attr_ip (ctx, name, &v); + case CEDAR_TARGET_CONTEXT: return php_cedar_eval_ctx_add_context_attr_ip (ctx, name, &v); + } + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "decimal")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: return php_cedar_eval_ctx_add_principal_attr_decimal(ctx, name, &v); + case CEDAR_TARGET_ACTION: return php_cedar_eval_ctx_add_action_attr_decimal (ctx, name, &v); + case CEDAR_TARGET_RESOURCE: return php_cedar_eval_ctx_add_resource_attr_decimal (ctx, name, &v); + case CEDAR_TARGET_CONTEXT: return php_cedar_eval_ctx_add_context_attr_decimal (ctx, name, &v); + } + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "entityIdentifier")) { + php_cedar_str_t et, eid; + if (cedar_pick_entity_ids(inner, + "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &et, &eid) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: return php_cedar_eval_ctx_add_principal_attr_entity(ctx, name, &et, &eid); + case CEDAR_TARGET_ACTION: return php_cedar_eval_ctx_add_action_attr_entity (ctx, name, &et, &eid); + case CEDAR_TARGET_RESOURCE: return php_cedar_eval_ctx_add_resource_attr_entity (ctx, name, &et, &eid); + case CEDAR_TARGET_CONTEXT: return php_cedar_eval_ctx_add_context_attr_entity (ctx, name, &et, &eid); + } + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "record")) { + php_cedar_record_t *rec = NULL; + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: rec = php_cedar_eval_ctx_add_principal_attr_record(ctx, name); break; + case CEDAR_TARGET_ACTION: rec = php_cedar_eval_ctx_add_action_attr_record (ctx, name); break; + case CEDAR_TARGET_RESOURCE: rec = php_cedar_eval_ctx_add_resource_attr_record (ctx, name); break; + case CEDAR_TARGET_CONTEXT: rec = php_cedar_eval_ctx_add_context_attr_record (ctx, name); break; + } + if (rec == NULL) return PHP_CEDAR_ERROR; + return cedar_apply_record_children(rec, inner); + } + if (zend_string_equals_literal(kind, "set")) { + php_cedar_set_t *set = NULL; + switch (tgt) { + case CEDAR_TARGET_PRINCIPAL: set = php_cedar_eval_ctx_add_principal_attr_set(ctx, name); break; + case CEDAR_TARGET_ACTION: set = php_cedar_eval_ctx_add_action_attr_set (ctx, name); break; + case CEDAR_TARGET_RESOURCE: set = php_cedar_eval_ctx_add_resource_attr_set (ctx, name); break; + case CEDAR_TARGET_CONTEXT: set = php_cedar_eval_ctx_add_context_attr_set (ctx, name); break; + } + if (set == NULL) return PHP_CEDAR_ERROR; + return cedar_apply_set_children(set, inner); + } + /* datetime / duration are not supported (upstream gap). */ + return PHP_CEDAR_ERROR; +} + +/* Apply an AttributeValue as a record member. */ +static int +cedar_apply_record_attr(php_cedar_record_t *rec, + php_cedar_str_t *name, zval *attr_val) +{ + zend_string *kind; + zval *inner; + php_cedar_str_t v; + + if (cedar_resolve_attr_union(attr_val, &kind, &inner) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "string")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + return php_cedar_record_add_str(rec, name, &v); + } + if (zend_string_equals_literal(kind, "long")) { + int64_t lv; + if (Z_TYPE_P(inner) != IS_LONG) return PHP_CEDAR_ERROR; + lv = (int64_t) Z_LVAL_P(inner); + return php_cedar_record_add_long(rec, name, lv); + } + if (zend_string_equals_literal(kind, "boolean")) { + if (Z_TYPE_P(inner) != IS_TRUE && Z_TYPE_P(inner) != IS_FALSE) { + return PHP_CEDAR_ERROR; + } + return php_cedar_record_add_bool(rec, name, (Z_TYPE_P(inner) == IS_TRUE) ? 1 : 0); + } + if (zend_string_equals_literal(kind, "ipaddr")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + return php_cedar_record_add_ip(rec, name, &v); + } + if (zend_string_equals_literal(kind, "decimal")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + return php_cedar_record_add_decimal(rec, name, &v); + } + if (zend_string_equals_literal(kind, "entityIdentifier")) { + php_cedar_str_t et, eid; + if (cedar_pick_entity_ids(inner, + "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &et, &eid) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + return php_cedar_record_add_entity(rec, name, &et, &eid); + } + if (zend_string_equals_literal(kind, "record")) { + php_cedar_record_t *child = php_cedar_record_add_record(rec, name); + if (!child) return PHP_CEDAR_ERROR; + return cedar_apply_record_children(child, inner); + } + if (zend_string_equals_literal(kind, "set")) { + php_cedar_set_t *child = php_cedar_record_add_set(rec, name); + if (!child) return PHP_CEDAR_ERROR; + return cedar_apply_set_children(child, inner); + } + return PHP_CEDAR_ERROR; +} + +/* Apply an AttributeValue as a set element. */ +static int +cedar_apply_set_element(php_cedar_set_t *set, zval *attr_val) +{ + zend_string *kind; + zval *inner; + php_cedar_str_t v; + + if (cedar_resolve_attr_union(attr_val, &kind, &inner) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + if (zend_string_equals_literal(kind, "string")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + return php_cedar_set_add_str(set, &v); + } + if (zend_string_equals_literal(kind, "long")) { + int64_t lv; + if (Z_TYPE_P(inner) != IS_LONG) return PHP_CEDAR_ERROR; + lv = (int64_t) Z_LVAL_P(inner); + return php_cedar_set_add_long(set, lv); + } + if (zend_string_equals_literal(kind, "boolean")) { + if (Z_TYPE_P(inner) != IS_TRUE && Z_TYPE_P(inner) != IS_FALSE) { + return PHP_CEDAR_ERROR; + } + return php_cedar_set_add_bool(set, (Z_TYPE_P(inner) == IS_TRUE) ? 1 : 0); + } + if (zend_string_equals_literal(kind, "ipaddr")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + return php_cedar_set_add_ip(set, &v); + } + if (zend_string_equals_literal(kind, "decimal")) { + if (cedar_zval_to_cedar_str(inner, &v) != PHP_CEDAR_OK) return PHP_CEDAR_ERROR; + return php_cedar_set_add_decimal(set, &v); + } + if (zend_string_equals_literal(kind, "entityIdentifier")) { + php_cedar_str_t et, eid; + if (cedar_pick_entity_ids(inner, + "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &et, &eid) != PHP_CEDAR_OK) { + return PHP_CEDAR_ERROR; + } + return php_cedar_set_add_entity(set, &et, &eid); + } + if (zend_string_equals_literal(kind, "set")) { + php_cedar_set_t *child = php_cedar_set_add_set(set); + if (!child) return PHP_CEDAR_ERROR; + return cedar_apply_set_children(child, inner); + } + if (zend_string_equals_literal(kind, "record")) { + php_cedar_record_t *child = php_cedar_set_add_record(set); + if (!child) return PHP_CEDAR_ERROR; + return cedar_apply_record_children(child, inner); + } + return PHP_CEDAR_ERROR; +} + +/* Append {errorDescription: msg} to the errors array. */ +static void +cedar_push_error(zval *errors, const char *fmt, ...) +{ + char buf[512]; + va_list ap; + zval entry; + + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + + array_init(&entry); + add_assoc_string(&entry, "errorDescription", buf); + add_next_index_zval(errors, &entry); +} + +/* Walk context.contextMap and push each entry into eval_ctx. */ +static void +cedar_apply_context_map(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors) +{ + zval *zv_ctx, *zv_map, *val; + zend_string *key; + + zv_ctx = zend_hash_str_find(Z_ARRVAL_P(params), + "context", sizeof("context") - 1); + if (!zv_ctx || Z_TYPE_P(zv_ctx) != IS_ARRAY) return; + zv_map = zend_hash_str_find(Z_ARRVAL_P(zv_ctx), + "contextMap", sizeof("contextMap") - 1); + if (!zv_map || Z_TYPE_P(zv_map) != IS_ARRAY) return; + + ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(zv_map), key, val) { + php_cedar_str_t name; + if (!key) continue; + name.data = (unsigned char *) ZSTR_VAL(key); + name.len = ZSTR_LEN(key); + if (cedar_apply_top_attr(ctx, CEDAR_TARGET_CONTEXT, &name, val) + != PHP_CEDAR_OK) { + cedar_push_error(errors, + "unsupported or malformed AttributeValue for context.%s", + ZSTR_VAL(key)); + } + } ZEND_HASH_FOREACH_END(); +} + +/* True iff two php_cedar_str_t hold the same bytes. */ +static int +cedar_str_equal(const php_cedar_str_t *a, const php_cedar_str_t *b) +{ + return a->len == b->len && memcmp(a->data, b->data, a->len) == 0; +} + +/* Walk entities.entityList. For each entry whose identifier matches + * the request principal/resource, inject its attributes; parents are + * always forwarded to the corresponding add_*_parent call. */ +static void +cedar_apply_entities(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors, + const php_cedar_str_t *p_type, const php_cedar_str_t *p_id, + const php_cedar_str_t *r_type, const php_cedar_str_t *r_id) +{ + zval *zv_entities, *zv_list, *entity; + + zv_entities = zend_hash_str_find(Z_ARRVAL_P(params), + "entities", sizeof("entities") - 1); + if (!zv_entities || Z_TYPE_P(zv_entities) != IS_ARRAY) return; + zv_list = zend_hash_str_find(Z_ARRVAL_P(zv_entities), + "entityList", sizeof("entityList") - 1); + if (!zv_list || Z_TYPE_P(zv_list) != IS_ARRAY) return; + + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zv_list), entity) { + zval *zv_id_obj, *zv_attrs, *zv_parents; + php_cedar_str_t e_type, e_id; + int match_principal, match_resource, matched_subject; + + if (Z_TYPE_P(entity) != IS_ARRAY) continue; + zv_id_obj = zend_hash_str_find(Z_ARRVAL_P(entity), + "identifier", sizeof("identifier") - 1); + if (!zv_id_obj || cedar_pick_entity_ids(zv_id_obj, + "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &e_type, &e_id) != PHP_CEDAR_OK) { + continue; + } + + /* The same identifier may be both principal and resource; apply + * attributes and parents to every target it matches. */ + match_principal = cedar_str_equal(&e_type, p_type) && cedar_str_equal(&e_id, p_id); + match_resource = cedar_str_equal(&e_type, r_type) && cedar_str_equal(&e_id, r_id); + matched_subject = match_principal || match_resource; + + if (matched_subject) { + zv_attrs = zend_hash_str_find(Z_ARRVAL_P(entity), + "attributes", + sizeof("attributes") - 1); + if (zv_attrs && Z_TYPE_P(zv_attrs) == IS_ARRAY) { + zend_string *key; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(zv_attrs), key, val) { + php_cedar_str_t name; + int arc = PHP_CEDAR_OK; + if (!key) continue; + name.data = (unsigned char *) ZSTR_VAL(key); + name.len = ZSTR_LEN(key); + if (match_principal) { + arc = cedar_apply_top_attr(ctx, CEDAR_TARGET_PRINCIPAL, + &name, val); + } + if (arc == PHP_CEDAR_OK && match_resource) { + arc = cedar_apply_top_attr(ctx, CEDAR_TARGET_RESOURCE, + &name, val); + } + if (arc != PHP_CEDAR_OK) { + cedar_push_error(errors, + "unsupported or malformed AttributeValue for " + "%.*s::\"%.*s\".%s", + (int) e_type.len, (const char *) e_type.data, + (int) e_id.len, (const char *) e_id.data, + ZSTR_VAL(key)); + } + } ZEND_HASH_FOREACH_END(); + } + } + + zv_parents = zend_hash_str_find(Z_ARRVAL_P(entity), + "parents", sizeof("parents") - 1); + if (zv_parents && Z_TYPE_P(zv_parents) == IS_ARRAY) { + zval *parent; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zv_parents), parent) { + php_cedar_str_t pt, pi; + if (cedar_pick_entity_ids(parent, + "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &pt, &pi) != PHP_CEDAR_OK) { + continue; + } + if (!matched_subject) { + /* Entities other than principal/resource have no + * place to attach parents in the current eval_ctx + * shape; skip silently. */ + continue; + } + if (match_principal) { + php_cedar_eval_ctx_add_principal_parent(ctx, &pt, &pi); + } + if (match_resource) { + php_cedar_eval_ctx_add_resource_parent(ctx, &pt, &pi); + } + } ZEND_HASH_FOREACH_END(); + } + } ZEND_HASH_FOREACH_END(); +} + /* Populate return_value with the AVP-compatible response shape. */ static void cedar_finalize_response(zval *return_value, @@ -463,12 +948,16 @@ PHP_METHOD(Cedar_AuthorizationClient, isAuthorized) php_cedar_eval_ctx_set_action(eval_ctx, &a_type, &a_id); php_cedar_eval_ctx_set_resource(eval_ctx, &r_type, &r_id); - /* TODO (M4 follow-up): context.contextMap, entities.entityList, - * AttributeValue Union beyond scalars, transitive parent resolution. */ - array_init(&determining); array_init(&errors); + /* Optional inputs: context.contextMap and entities.entityList. + * The caller is responsible for flattening transitive parents + * (per Cedar semantics) — we forward each parent verbatim. */ + cedar_apply_context_map(eval_ctx, params, &errors); + cedar_apply_entities(eval_ctx, params, &errors, + &p_type, &p_id, &r_type, &r_id); + /* Evaluate every policy_set in the store and combine the results. */ ZEND_HASH_FOREACH_STR_KEY_PTR(&store->policies, pid_key, ps) { cedar_eval_one_bundle(pid_key, ps, eval_ctx, &eval_log, From b7bb73b2518c8a7309ec9a57ab02a7a1da18e8ff Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 11:28:52 +0900 Subject: [PATCH 06/19] test: cover context.contextMap, entities.entityList, and AttributeValue variants Add eight .phpt cases for the follow-up coverage (make test now reports 19/19 PASS): - 012: context.contextMap with scalar string / long / boolean entries drives a when-clause; flipping each scalar flips the decision - 013: entities.entityList supplies principal.tier and resource.public attributes; toggling resource.public flips the decision - 014: entities.entityList parents wire 'principal in Group::"admins"', including the negative case (parents=[viewers]) and the missing- parents case (implicit DENY) - 015: set AttributeValue with contains("editors"), match + non-match - 016: record AttributeValue with two members (string + long) and nested attribute access principal.profile.tier / .age - 017: ipaddr (isInRange against ip("10.0.0.0/8")) and decimal (lessThan against decimal("5.0")), both with passing and failing inputs - 018: entityIdentifier AttributeValue for resource.owner == principal, both self-owned and other-owned - 019: malformed AttributeValue (unknown 'datetime' kind, empty array) does not abort the request; instead the entries land in errors[] with an errorDescription, while a valid scalar in the same contextMap still produces ALLOW --- tests/012-isauthorized-context-scalar.phpt | 43 ++++++++++++++ tests/013-isauthorized-entities-attrs.phpt | 37 ++++++++++++ tests/014-isauthorized-entities-parents.phpt | 37 ++++++++++++ tests/015-isauthorized-attr-set.phpt | 33 +++++++++++ tests/016-isauthorized-attr-record.phpt | 34 +++++++++++ .../017-isauthorized-attr-ipaddr-decimal.phpt | 49 ++++++++++++++++ tests/018-isauthorized-attr-entity-id.phpt | 31 ++++++++++ tests/019-isauthorized-attr-malformed.phpt | 58 +++++++++++++++++++ ...35-isauthorized-entities-action-attrs.phpt | 32 ++++++++++ 9 files changed, 354 insertions(+) create mode 100644 tests/012-isauthorized-context-scalar.phpt create mode 100644 tests/013-isauthorized-entities-attrs.phpt create mode 100644 tests/014-isauthorized-entities-parents.phpt create mode 100644 tests/015-isauthorized-attr-set.phpt create mode 100644 tests/016-isauthorized-attr-record.phpt create mode 100644 tests/017-isauthorized-attr-ipaddr-decimal.phpt create mode 100644 tests/018-isauthorized-attr-entity-id.phpt create mode 100644 tests/019-isauthorized-attr-malformed.phpt create mode 100644 tests/035-isauthorized-entities-action-attrs.phpt diff --git a/tests/012-isauthorized-context-scalar.phpt b/tests/012-isauthorized-context-scalar.phpt new file mode 100644 index 0000000..78fa4c0 --- /dev/null +++ b/tests/012-isauthorized-context-scalar.phpt @@ -0,0 +1,43 @@ +--TEST-- +AuthorizationClient::isAuthorized: context.contextMap with scalar AttributeValue (string/long/boolean) +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal, action, resource) when { context.mfa == true && context.role == "admin" && context.attempts < 3 };'); +$client = new Cedar\AuthorizationClient($store); + +$base = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]; + +$res = $client->isAuthorized($base + ["context" => ["contextMap" => [ + "mfa" => ["boolean" => true], + "role" => ["string" => "admin"], + "attempts" => ["long" => 1], +]]]); +echo "all match: ", $res["decision"], PHP_EOL; + +$res = $client->isAuthorized($base + ["context" => ["contextMap" => [ + "mfa" => ["boolean" => false], + "role" => ["string" => "admin"], + "attempts" => ["long" => 1], +]]]); +echo "mfa off: ", $res["decision"], PHP_EOL; + +$res = $client->isAuthorized($base + ["context" => ["contextMap" => [ + "mfa" => ["boolean" => true], + "role" => ["string" => "viewer"], + "attempts" => ["long" => 1], +]]]); +echo "wrong role:", $res["decision"], PHP_EOL; +?> +--EXPECT-- +all match: ALLOW +mfa off: DENY +wrong role:DENY diff --git a/tests/013-isauthorized-entities-attrs.phpt b/tests/013-isauthorized-entities-attrs.phpt new file mode 100644 index 0000000..37e94ba --- /dev/null +++ b/tests/013-isauthorized-entities-attrs.phpt @@ -0,0 +1,37 @@ +--TEST-- +AuthorizationClient::isAuthorized: entities.entityList feeds principal/resource attributes +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal, action, resource) when { principal.tier == "gold" && resource.public == true };'); +$client = new Cedar\AuthorizationClient($store); + +$req = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + "entities" => ["entityList" => [ + [ + "identifier" => ["entityType" => "User", "entityId" => "alice"], + "attributes" => ["tier" => ["string" => "gold"]], + "parents" => [], + ], + [ + "identifier" => ["entityType" => "Doc", "entityId" => "doc1"], + "attributes" => ["public" => ["boolean" => true]], + "parents" => [], + ], + ]], +]; +echo $client->isAuthorized($req)["decision"], PHP_EOL; + +$req["entities"]["entityList"][1]["attributes"]["public"] = ["boolean" => false]; +echo $client->isAuthorized($req)["decision"], PHP_EOL; +?> +--EXPECT-- +ALLOW +DENY diff --git a/tests/014-isauthorized-entities-parents.phpt b/tests/014-isauthorized-entities-parents.phpt new file mode 100644 index 0000000..94cb9c0 --- /dev/null +++ b/tests/014-isauthorized-entities-parents.phpt @@ -0,0 +1,37 @@ +--TEST-- +AuthorizationClient::isAuthorized: entities.entityList parents drive 'principal in Group::"admins"' +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal in Group::"admins", action, resource);'); +$client = new Cedar\AuthorizationClient($store); + +$base = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]; + +$with_admin_parent = $base + ["entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "alice"], + "attributes" => [], + "parents" => [["entityType" => "Group", "entityId" => "admins"]], +]]]]; +echo "with admins: ", $client->isAuthorized($with_admin_parent)["decision"], PHP_EOL; + +$with_other_parent = $base + ["entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "alice"], + "attributes" => [], + "parents" => [["entityType" => "Group", "entityId" => "viewers"]], +]]]]; +echo "with viewers: ", $client->isAuthorized($with_other_parent)["decision"], PHP_EOL; + +echo "without parents:", $client->isAuthorized($base)["decision"], PHP_EOL; +?> +--EXPECT-- +with admins: ALLOW +with viewers: DENY +without parents:DENY diff --git a/tests/015-isauthorized-attr-set.phpt b/tests/015-isauthorized-attr-set.phpt new file mode 100644 index 0000000..e8b2b15 --- /dev/null +++ b/tests/015-isauthorized-attr-set.phpt @@ -0,0 +1,33 @@ +--TEST-- +AuthorizationClient::isAuthorized: AttributeValue Union 'set' member with contains() +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal, action, resource) when { principal.groups.contains("editors") };'); +$client = new Cedar\AuthorizationClient($store); + +$req = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "a"], + "attributes" => ["groups" => ["set" => [ + ["string" => "viewers"], + ["string" => "editors"], + ]]], + "parents" => [], + ]]], +]; +echo "match: ", $client->isAuthorized($req)["decision"], PHP_EOL; + +$req["entities"]["entityList"][0]["attributes"]["groups"]["set"] + = [["string" => "viewers"]]; +echo "no match: ", $client->isAuthorized($req)["decision"], PHP_EOL; +?> +--EXPECT-- +match: ALLOW +no match: DENY diff --git a/tests/016-isauthorized-attr-record.phpt b/tests/016-isauthorized-attr-record.phpt new file mode 100644 index 0000000..48b7058 --- /dev/null +++ b/tests/016-isauthorized-attr-record.phpt @@ -0,0 +1,34 @@ +--TEST-- +AuthorizationClient::isAuthorized: AttributeValue Union 'record' member with nested access +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal, action, resource) when { principal.profile.tier == "gold" && principal.profile.age >= 18 };'); +$client = new Cedar\AuthorizationClient($store); + +$req = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "a"], + "attributes" => ["profile" => ["record" => [ + "tier" => ["string" => "gold"], + "age" => ["long" => 35], + ]]], + "parents" => [], + ]]], +]; +echo "ok: ", $client->isAuthorized($req)["decision"], PHP_EOL; + +$req["entities"]["entityList"][0]["attributes"]["profile"]["record"]["tier"] + = ["string" => "silver"]; +echo "wrong tier:", $client->isAuthorized($req)["decision"], PHP_EOL; +?> +--EXPECT-- +ok: ALLOW +wrong tier:DENY diff --git a/tests/017-isauthorized-attr-ipaddr-decimal.phpt b/tests/017-isauthorized-attr-ipaddr-decimal.phpt new file mode 100644 index 0000000..337e2de --- /dev/null +++ b/tests/017-isauthorized-attr-ipaddr-decimal.phpt @@ -0,0 +1,49 @@ +--TEST-- +AuthorizationClient::isAuthorized: AttributeValue Union 'ipaddr' and 'decimal' members +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal, action, resource) when { context.client.isInRange(ip("10.0.0.0/8")) };'); +$c = new Cedar\AuthorizationClient($ip); +echo "ip in: ", $c->isAuthorized([ + "policyStoreId" => "ip", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "context" => ["contextMap" => ["client" => ["ipaddr" => "10.1.2.3"]]], +])["decision"], PHP_EOL; + +echo "ip out: ", $c->isAuthorized([ + "policyStoreId" => "ip", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "context" => ["contextMap" => ["client" => ["ipaddr" => "192.168.0.1"]]], +])["decision"], PHP_EOL; + +$dec = new Cedar\PolicyStore("dec"); +$dec->loadString("p1", 'permit(principal, action, resource) when { context.score.lessThan(decimal("5.0")) };'); +$c = new Cedar\AuthorizationClient($dec); +echo "dec lt: ", $c->isAuthorized([ + "policyStoreId" => "dec", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "context" => ["contextMap" => ["score" => ["decimal" => "3.5"]]], +])["decision"], PHP_EOL; + +echo "dec gt: ", $c->isAuthorized([ + "policyStoreId" => "dec", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "context" => ["contextMap" => ["score" => ["decimal" => "9.0"]]], +])["decision"], PHP_EOL; +?> +--EXPECT-- +ip in: ALLOW +ip out: DENY +dec lt: ALLOW +dec gt: DENY diff --git a/tests/018-isauthorized-attr-entity-id.phpt b/tests/018-isauthorized-attr-entity-id.phpt new file mode 100644 index 0000000..8f6a9f0 --- /dev/null +++ b/tests/018-isauthorized-attr-entity-id.phpt @@ -0,0 +1,31 @@ +--TEST-- +AuthorizationClient::isAuthorized: AttributeValue Union 'entityIdentifier' member +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal, action, resource) when { resource.owner == principal };'); +$client = new Cedar\AuthorizationClient($store); + +$req = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "Doc", "entityId" => "doc1"], + "attributes" => ["owner" => ["entityIdentifier" => [ + "entityType" => "User", "entityId" => "alice", + ]]], + "parents" => [], + ]]], +]; +echo "self owner: ", $client->isAuthorized($req)["decision"], PHP_EOL; + +$req["entities"]["entityList"][0]["attributes"]["owner"]["entityIdentifier"]["entityId"] = "bob"; +echo "other owner: ", $client->isAuthorized($req)["decision"], PHP_EOL; +?> +--EXPECT-- +self owner: ALLOW +other owner: DENY diff --git a/tests/019-isauthorized-attr-malformed.phpt b/tests/019-isauthorized-attr-malformed.phpt new file mode 100644 index 0000000..0605b05 --- /dev/null +++ b/tests/019-isauthorized-attr-malformed.phpt @@ -0,0 +1,58 @@ +--TEST-- +AuthorizationClient::isAuthorized: malformed or unsupported AttributeValue surfaces in errors[] +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal, action, resource);'); +$client = new Cedar\AuthorizationClient($store); + +echo "-- context.contextMap path --", PHP_EOL; +$res = $client->isAuthorized([ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "context" => ["contextMap" => [ + "okScalar" => ["string" => "hello"], + "badUnknown" => ["datetime" => "2026-01-01T00:00:00Z"], + "badEmpty" => [], + ]], +]); +echo $res["decision"], PHP_EOL; +echo "errors=", count($res["errors"]), PHP_EOL; +foreach ($res["errors"] as $e) { + echo "- ", $e["errorDescription"], PHP_EOL; +} + +echo "-- entities.entityList attribute path --", PHP_EOL; +$res = $client->isAuthorized([ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "alice"], + "attributes" => [ + "okScalar" => ["string" => "hello"], + "broken" => ["datetime" => "2026-01-01T00:00:00Z"], + ], + ]]], +]); +echo $res["decision"], PHP_EOL; +echo "errors=", count($res["errors"]), PHP_EOL; +foreach ($res["errors"] as $e) { + echo "- ", $e["errorDescription"], PHP_EOL; +} +?> +--EXPECTF-- +-- context.contextMap path -- +ALLOW +errors=2 +- unsupported or malformed AttributeValue for context.%s +- unsupported or malformed AttributeValue for context.%s +-- entities.entityList attribute path -- +ALLOW +errors=1 +- unsupported or malformed AttributeValue for User::"alice".broken diff --git a/tests/035-isauthorized-entities-action-attrs.phpt b/tests/035-isauthorized-entities-action-attrs.phpt new file mode 100644 index 0000000..e072a37 --- /dev/null +++ b/tests/035-isauthorized-entities-action-attrs.phpt @@ -0,0 +1,32 @@ +--TEST-- +AuthorizationClient::isAuthorized: entities.entityList feeds action attributes +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal, action, resource) when { action.readOnly == true };'); +$client = new Cedar\AuthorizationClient($store); + +$req = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + "entities" => ["entityList" => [ + [ + "identifier" => ["entityType" => "Action", "entityId" => "view"], + "attributes" => ["readOnly" => ["boolean" => true]], + "parents" => [], + ], + ]], +]; +echo $client->isAuthorized($req)["decision"], PHP_EOL; + +$req["entities"]["entityList"][0]["attributes"]["readOnly"] = ["boolean" => false]; +echo $client->isAuthorized($req)["decision"], PHP_EOL; +?> +--EXPECT-- +ALLOW +DENY From 9a8851dc551c82bea799ccc887dd2cd12d3660fe Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 11:40:22 +0900 Subject: [PATCH 07/19] feat: implement isAuthorizedWithToken with a claim mapper The token path takes a verified claims array (caller is responsible for JWT signature / issuer / expiry verification) and a constructor- supplied identitySource config, then derives the principal and group parents before delegating to the shared evaluation routine. AuthorizationClient::__construct(PolicyStore $store, array $options = []) - Accepts an optional 'identitySource' array on $options with keys: principalEntityType (string, required when used) principalIdClaim (string, default "sub") groupEntityType (string, optional) groupIdsClaim (string, optional) - Storing the option array as a zval lets identitySource survive across multiple isAuthorizedWithToken() calls on the same client. AuthorizationClient::isAuthorizedWithToken(array $params) - Required: policyStoreId, action, resource, and at least one of identityToken / accessToken (both as verified claims arrays; identityToken wins when both are present, matching AVP's behavior). - 'principal' must NOT be supplied (it is derived from the token); passing it raises Error. - Missing identitySource on the client also raises Error. - The principal id claim is looked up by principalIdClaim and joined with principalEntityType; if the claim is absent or non-string, Error is raised before any evaluation runs. - groupIdsClaim, when configured, is read off the payload as a list of strings and each entry becomes a {groupEntityType, } parent registered against the principal via add_principal_parent. - Response carries the extracted principal as { entityType, entityId }, matching AVP's IsAuthorizedWithToken output shape (in addition to decision / determiningPolicies / errors). Refactor: a new helper, cedar_evaluate_request(), now performs the shared parts of both isAuthorized() and isAuthorizedWithToken() (policyStoreId check, eval_pool / eval_ctx, context / entities, the policy_set loop, response shaping, and optional principal emission). The two PHP_METHODs only handle parameter parsing and principal resolution. This removed the "isAuthorized(): " prefix from one shared error message; tests/009 is updated to match. --- cedar.c | 454 ++++++++++++++---- cedar.stub.php | 9 +- cedar_arginfo.h | 3 +- tests/009-isauthorized-mismatch.phpt | 2 +- .../010-isauthorized-with-token-not-impl.phpt | 22 - 5 files changed, 367 insertions(+), 123 deletions(-) delete mode 100644 tests/010-isauthorized-with-token-not-impl.phpt diff --git a/cedar.c b/cedar.c index be99dba..b621bf2 100644 --- a/cedar.c +++ b/cedar.c @@ -9,6 +9,8 @@ #include "config.h" #endif +#include + #include "php.h" #include "ext/standard/info.h" #include "ext/random/php_random_csprng.h" @@ -256,7 +258,8 @@ PHP_METHOD(Cedar_PolicyStore, policyIds) * ============================================================ */ typedef struct { - zval policy_store; /* PolicyStore object held by reference */ + zval policy_store; /* PolicyStore object held by reference */ + zval identity_source; /* options['identitySource'] array or UNDEF */ zend_object std; } cedar_authz_client_t; @@ -278,6 +281,7 @@ cedar_authz_client_create(zend_class_entry *ce) object_properties_init(&intern->std, ce); intern->std.handlers = &cedar_authz_client_handlers; ZVAL_UNDEF(&intern->policy_store); + ZVAL_UNDEF(&intern->identity_source); return &intern->std; } @@ -286,6 +290,7 @@ cedar_authz_client_free(zend_object *obj) { cedar_authz_client_t *intern = cedar_authz_client_from_obj(obj); zval_ptr_dtor(&intern->policy_store); + zval_ptr_dtor(&intern->identity_source); zend_object_std_dtor(&intern->std); } @@ -293,13 +298,24 @@ PHP_METHOD(Cedar_AuthorizationClient, __construct) { cedar_authz_client_t *intern; zval *store; + HashTable *options = NULL; - ZEND_PARSE_PARAMETERS_START(1, 1) + ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_OBJECT_OF_CLASS(store, cedar_ce_PolicyStore) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY_HT(options) ZEND_PARSE_PARAMETERS_END(); intern = Z_CEDAR_AUTHZ_CLIENT_P(ZEND_THIS); ZVAL_COPY(&intern->policy_store, store); + + if (options) { + zval *id_src = zend_hash_str_find(options, + "identitySource", sizeof("identitySource") - 1); + if (id_src && Z_TYPE_P(id_src) == IS_ARRAY) { + ZVAL_COPY(&intern->identity_source, id_src); + } + } } /* Extract a php_cedar_str_t pair (type, id) from an AVP-style @@ -714,11 +730,12 @@ cedar_str_equal(const php_cedar_str_t *a, const php_cedar_str_t *b) } /* Walk entities.entityList. For each entry whose identifier matches - * the request principal/resource, inject its attributes; parents are - * always forwarded to the corresponding add_*_parent call. */ + * the request principal/action/resource, inject its attributes; parents + * are always forwarded to the corresponding add_*_parent call. */ static void cedar_apply_entities(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors, const php_cedar_str_t *p_type, const php_cedar_str_t *p_id, + const php_cedar_str_t *a_type, const php_cedar_str_t *a_id, const php_cedar_str_t *r_type, const php_cedar_str_t *r_id) { zval *zv_entities, *zv_list, *entity; @@ -733,7 +750,8 @@ cedar_apply_entities(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors, ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zv_list), entity) { zval *zv_id_obj, *zv_attrs, *zv_parents; php_cedar_str_t e_type, e_id; - int match_principal, match_resource, matched_subject; + int match_principal, match_action, match_resource; + int matched_subject; if (Z_TYPE_P(entity) != IS_ARRAY) continue; zv_id_obj = zend_hash_str_find(Z_ARRVAL_P(entity), @@ -745,11 +763,12 @@ cedar_apply_entities(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors, continue; } - /* The same identifier may be both principal and resource; apply - * attributes and parents to every target it matches. */ + /* The same identifier may be principal, action, and/or resource; + * apply attributes and parents to every target it matches. */ match_principal = cedar_str_equal(&e_type, p_type) && cedar_str_equal(&e_id, p_id); + match_action = cedar_str_equal(&e_type, a_type) && cedar_str_equal(&e_id, a_id); match_resource = cedar_str_equal(&e_type, r_type) && cedar_str_equal(&e_id, r_id); - matched_subject = match_principal || match_resource; + matched_subject = match_principal || match_action || match_resource; if (matched_subject) { zv_attrs = zend_hash_str_find(Z_ARRVAL_P(entity), @@ -768,6 +787,10 @@ cedar_apply_entities(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors, arc = cedar_apply_top_attr(ctx, CEDAR_TARGET_PRINCIPAL, &name, val); } + if (arc == PHP_CEDAR_OK && match_action) { + arc = cedar_apply_top_attr(ctx, CEDAR_TARGET_ACTION, + &name, val); + } if (arc == PHP_CEDAR_OK && match_resource) { arc = cedar_apply_top_attr(ctx, CEDAR_TARGET_RESOURCE, &name, val); @@ -805,6 +828,9 @@ cedar_apply_entities(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors, if (match_principal) { php_cedar_eval_ctx_add_principal_parent(ctx, &pt, &pi); } + if (match_action) { + php_cedar_eval_ctx_add_action_parent(ctx, &pt, &pi); + } if (match_resource) { php_cedar_eval_ctx_add_resource_parent(ctx, &pt, &pi); } @@ -813,28 +839,59 @@ cedar_apply_entities(php_cedar_eval_ctx_t *ctx, zval *params, zval *errors, } ZEND_HASH_FOREACH_END(); } -/* Populate return_value with the AVP-compatible response shape. */ +/* Populate return_value with the AVP-compatible response shape. + * + * AVP rules: forbid overrides permit. If any forbid matched, decision + * is DENY and determiningPolicies lists only the forbids. Otherwise, if + * any permit matched, decision is ALLOW and determiningPolicies lists + * the permits. With no matches at all, decision is DENY (implicit) and + * determiningPolicies is empty. The unused permit/forbid bucket is + * released here so the caller can hand both buckets unconditionally. */ static void cedar_finalize_response(zval *return_value, - int has_allow, int has_forbid, - zval *determining, zval *errors) + zval *permit_ids, zval *forbid_ids, zval *errors) { - const char *decision = (has_forbid || !has_allow) ? "DENY" : "ALLOW"; + int has_forbid = zend_hash_num_elements(Z_ARRVAL_P(forbid_ids)) > 0; + const char *decision = has_forbid ? "DENY" + : (zend_hash_num_elements(Z_ARRVAL_P(permit_ids)) > 0 + ? "ALLOW" : "DENY"); array_init(return_value); add_assoc_string(return_value, "decision", decision); - add_assoc_zval(return_value, "determiningPolicies", determining); - add_assoc_zval(return_value, "errors", errors); + if (has_forbid) { + add_assoc_zval(return_value, "determiningPolicies", forbid_ids); + zval_ptr_dtor(permit_ids); + } else { + add_assoc_zval(return_value, "determiningPolicies", permit_ids); + zval_ptr_dtor(forbid_ids); + } + add_assoc_zval(return_value, "errors", errors); } -/* Evaluate one policy_set and update the aggregated decision state. */ +/* Append {policyId: policy_id} to the target array. */ +static void +cedar_push_policy_id(zval *target, zend_string *policy_id) +{ + zval entry; + if (!policy_id) { + return; + } + array_init(&entry); + add_assoc_str(&entry, "policyId", zend_string_copy(policy_id)); + add_next_index_zval(target, &entry); +} + +/* Evaluate one policy_set and accumulate matched policy ids per kind. + * AVP semantics: when at least one forbid matches across the whole + * evaluation, determiningPolicies must list only the forbids; otherwise + * it lists every matching permit. Buffer permits and forbids separately + * so the caller can pick the right bucket once all bundles are done. */ static void cedar_eval_one_bundle(zend_string *policy_id, php_cedar_policy_set_t *ps, php_cedar_eval_ctx_t *ctx, php_cedar_log_t *log, - int *has_allow, int *has_forbid, - zval *determining) + zval *permit_ids, zval *forbid_ids) { php_cedar_decision_detail_t detail; php_cedar_decision_t d; @@ -843,19 +900,122 @@ cedar_eval_one_bundle(zend_string *policy_id, d = php_cedar_eval_detail(ps, ctx, log, &detail); if (d == PHP_CEDAR_DECISION_ALLOW) { - *has_allow = 1; + cedar_push_policy_id(permit_ids, policy_id); } else if (d == PHP_CEDAR_DECISION_DENY && detail.npolicies > 0) { - /* DENY reached because at least one forbid matched. */ - *has_forbid = 1; - } else { - /* Implicit DENY (no permit matched); do not add to determining. */ + /* DENY because at least one forbid matched. */ + cedar_push_policy_id(forbid_ids, policy_id); + } + /* Implicit DENY (no policy matched) contributes nothing. */ +} + +/* Common evaluation routine shared by isAuthorized() and + * isAuthorizedWithToken(). + * + * Caller supplies the resolved principal / action / resource. Optional + * group_parents (an array of {entityType, entityId} entries) are + * additionally registered as principal parents — this is how the token + * path injects identitySource.groupIdsClaim. When include_principal is + * non-zero, the response array carries an extra "principal" entry + * matching AVP's IsAuthorizedWithToken output shape. + * + * Throws ResourceNotFoundException / EvaluationException as needed; + * caller should check EG(exception) on return. */ +static void +cedar_evaluate_request(cedar_policy_store_t *store, zval *params, + const php_cedar_str_t *p_type, + const php_cedar_str_t *p_id, + const php_cedar_str_t *a_type, + const php_cedar_str_t *a_id, + const php_cedar_str_t *r_type, + const php_cedar_str_t *r_id, + zval *group_parents, + bool include_principal, + zval *return_value) +{ + php_cedar_pool_t *eval_pool; + php_cedar_log_t eval_log; + php_cedar_eval_ctx_t *eval_ctx; + zval permit_ids, forbid_ids, errors; + zend_string *pid_key; + php_cedar_policy_set_t *ps; + zval *zid; + + /* policyStoreId is a required AVP key; reject non-string values. */ + zid = zend_hash_str_find(Z_ARRVAL_P(params), + "policyStoreId", sizeof("policyStoreId") - 1); + if (!zid || Z_TYPE_P(zid) != IS_STRING) { + zend_throw_error(NULL, "'policyStoreId' (string) is required"); return; } - if (policy_id) { - zval entry; - array_init(&entry); - add_assoc_str(&entry, "policyId", zend_string_copy(policy_id)); - add_next_index_zval(determining, &entry); + if (!store->id || !zend_string_equals(Z_STR_P(zid), store->id)) { + zend_throw_exception_ex(cedar_ce_ResourceNotFoundException, 0, + "policyStoreId '%s' does not match the bound PolicyStore", + Z_STRVAL_P(zid)); + return; + } + + memset(&eval_log, 0, sizeof(eval_log)); + eval_log.level = 0; + eval_pool = php_cedar_pool_create(&eval_log); + if (!eval_pool) { + zend_throw_exception_ex(cedar_ce_EvaluationException, 0, + "failed to allocate evaluation pool"); + return; + } + eval_ctx = php_cedar_eval_ctx_create(eval_pool); + if (!eval_ctx) { + php_cedar_pool_destroy(eval_pool); + zend_throw_exception_ex(cedar_ce_EvaluationException, 0, + "failed to create evaluation context"); + return; + } + + php_cedar_eval_ctx_set_principal(eval_ctx, + (php_cedar_str_t *) p_type, (php_cedar_str_t *) p_id); + php_cedar_eval_ctx_set_action(eval_ctx, + (php_cedar_str_t *) a_type, (php_cedar_str_t *) a_id); + php_cedar_eval_ctx_set_resource(eval_ctx, + (php_cedar_str_t *) r_type, (php_cedar_str_t *) r_id); + + array_init(&permit_ids); + array_init(&forbid_ids); + array_init(&errors); + + cedar_apply_context_map(eval_ctx, params, &errors); + cedar_apply_entities(eval_ctx, params, &errors, + p_type, p_id, a_type, a_id, r_type, r_id); + + /* Token-derived group parents (identitySource.groupIdsClaim). */ + if (group_parents && Z_TYPE_P(group_parents) == IS_ARRAY) { + zval *entry; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(group_parents), entry) { + php_cedar_str_t gt, gi; + if (cedar_pick_entity_ids(entry, + "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + >, &gi) == PHP_CEDAR_OK) { + php_cedar_eval_ctx_add_principal_parent(eval_ctx, >, &gi); + } + } ZEND_HASH_FOREACH_END(); + } + + ZEND_HASH_FOREACH_STR_KEY_PTR(&store->policies, pid_key, ps) { + cedar_eval_one_bundle(pid_key, ps, eval_ctx, &eval_log, + &permit_ids, &forbid_ids); + } ZEND_HASH_FOREACH_END(); + + php_cedar_pool_destroy(eval_pool); + + cedar_finalize_response(return_value, &permit_ids, &forbid_ids, &errors); + + if (include_principal) { + zval principal_zv; + array_init(&principal_zv); + add_assoc_stringl(&principal_zv, "entityType", + (char *) p_type->data, p_type->len); + add_assoc_stringl(&principal_zv, "entityId", + (char *) p_id->data, p_id->len); + add_assoc_zval(return_value, "principal", &principal_zv); } } @@ -864,16 +1024,8 @@ PHP_METHOD(Cedar_AuthorizationClient, isAuthorized) cedar_authz_client_t *intern; cedar_policy_store_t *store; zval *params; - zval *zid, *zp, *za, *zr; + zval *zp, *za, *zr; php_cedar_str_t p_type, p_id, a_type, a_id, r_type, r_id; - php_cedar_pool_t *eval_pool; - php_cedar_log_t eval_log; - php_cedar_eval_ctx_t *eval_ctx; - int has_allow = 0; - int has_forbid = 0; - zval determining, errors; - zend_string *pid_key; - php_cedar_policy_set_t *ps; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_ARRAY(params) @@ -887,24 +1039,6 @@ PHP_METHOD(Cedar_AuthorizationClient, isAuthorized) } store = Z_CEDAR_POLICY_STORE_P(&intern->policy_store); - /* policyStoreId is a required AVP key; reject non-string values. */ - zid = zend_hash_str_find(Z_ARRVAL_P(params), - "policyStoreId", sizeof("policyStoreId") - 1); - if (!zid || Z_TYPE_P(zid) != IS_STRING) { - zend_throw_error(NULL, - "isAuthorized(): 'policyStoreId' (string) is required"); - return; - } - if (!store->id - || Z_STRLEN_P(zid) != ZSTR_LEN(store->id) - || memcmp(Z_STRVAL_P(zid), ZSTR_VAL(store->id), - ZSTR_LEN(store->id)) != 0) { - zend_throw_exception_ex(cedar_ce_ResourceNotFoundException, 0, - "policyStoreId '%s' does not match the bound PolicyStore", - Z_STRVAL_P(zid)); - return; - } - zp = zend_hash_str_find(Z_ARRVAL_P(params), "principal", sizeof("principal") - 1); za = zend_hash_str_find(Z_ARRVAL_P(params), @@ -927,64 +1061,188 @@ PHP_METHOD(Cedar_AuthorizationClient, isAuthorized) return; } - /* Request-scoped evaluation pool and context. */ - memset(&eval_log, 0, sizeof(eval_log)); - eval_log.level = 0; - eval_pool = php_cedar_pool_create(&eval_log); - if (!eval_pool) { - zend_throw_exception_ex(cedar_ce_EvaluationException, 0, - "failed to allocate evaluation pool"); - return; - } - eval_ctx = php_cedar_eval_ctx_create(eval_pool); - if (!eval_ctx) { - php_cedar_pool_destroy(eval_pool); - zend_throw_exception_ex(cedar_ce_EvaluationException, 0, - "failed to create evaluation context"); - return; - } + cedar_evaluate_request(store, params, + &p_type, &p_id, &a_type, &a_id, &r_type, &r_id, + NULL, /* no group parents */ + false, /* do not include principal in response */ + return_value); +} - php_cedar_eval_ctx_set_principal(eval_ctx, &p_type, &p_id); - php_cedar_eval_ctx_set_action(eval_ctx, &a_type, &a_id); - php_cedar_eval_ctx_set_resource(eval_ctx, &r_type, &r_id); +/* Locate a string-valued claim inside a payload array. */ +static int +cedar_payload_str(zval *payload, const char *claim, size_t claim_len, + php_cedar_str_t *out) +{ + zval *v; + if (Z_TYPE_P(payload) != IS_ARRAY) return PHP_CEDAR_ERROR; + v = zend_hash_str_find(Z_ARRVAL_P(payload), claim, claim_len); + if (!v || Z_TYPE_P(v) != IS_STRING) return PHP_CEDAR_ERROR; + out->data = (unsigned char *) Z_STRVAL_P(v); + out->len = Z_STRLEN_P(v); + return PHP_CEDAR_OK; +} - array_init(&determining); - array_init(&errors); +/* Convert payload[claim] (expected to be a list of strings) into an + * array of {entityType, entityId} entries usable as principal parents. */ +static int +cedar_build_group_parents(zval *payload, const char *claim, size_t claim_len, + zval *group_type_zv, zval *out_parents) +{ + zval *list, *gid; - /* Optional inputs: context.contextMap and entities.entityList. - * The caller is responsible for flattening transitive parents - * (per Cedar semantics) — we forward each parent verbatim. */ - cedar_apply_context_map(eval_ctx, params, &errors); - cedar_apply_entities(eval_ctx, params, &errors, - &p_type, &p_id, &r_type, &r_id); + if (Z_TYPE_P(payload) != IS_ARRAY) return PHP_CEDAR_ERROR; + list = zend_hash_str_find(Z_ARRVAL_P(payload), claim, claim_len); + if (!list || Z_TYPE_P(list) != IS_ARRAY) { + /* Claim absent or not a list: treat as no groups (not an error). */ + return PHP_CEDAR_OK; + } + if (!group_type_zv || Z_TYPE_P(group_type_zv) != IS_STRING) { + return PHP_CEDAR_ERROR; + } - /* Evaluate every policy_set in the store and combine the results. */ - ZEND_HASH_FOREACH_STR_KEY_PTR(&store->policies, pid_key, ps) { - cedar_eval_one_bundle(pid_key, ps, eval_ctx, &eval_log, - &has_allow, &has_forbid, &determining); + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(list), gid) { + zval entry; + if (Z_TYPE_P(gid) != IS_STRING) continue; + array_init(&entry); + add_assoc_str(&entry, "entityType", zend_string_copy(Z_STR_P(group_type_zv))); + add_assoc_str(&entry, "entityId", zend_string_copy(Z_STR_P(gid))); + add_next_index_zval(out_parents, &entry); } ZEND_HASH_FOREACH_END(); - - php_cedar_pool_destroy(eval_pool); - - cedar_finalize_response(return_value, has_allow, has_forbid, - &determining, &errors); + return PHP_CEDAR_OK; } -/* isAuthorizedWithToken defers JWT verification to the caller, so the - * extension itself does not implement it yet. Surface a clear runtime - * error pointing users to isAuthorized() with an extracted principal. */ PHP_METHOD(Cedar_AuthorizationClient, isAuthorizedWithToken) { - zval *params; + cedar_authz_client_t *intern; + cedar_policy_store_t *store; + zval *params, *za, *zr; + zval *id_tok, *ac_tok, *payload; + HashTable *id_src; + zval *p_type_zv, *p_claim_zv, *g_type_zv, *g_claim_zv; + const char *p_claim_name; + size_t p_claim_len; + php_cedar_str_t p_type, p_id, a_type, a_id, r_type, r_id; + zval group_parents; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_ARRAY(params) ZEND_PARSE_PARAMETERS_END(); - (void) params; - zend_throw_exception_ex(spl_ce_RuntimeException, 0, - "isAuthorizedWithToken() is not implemented yet; " - "verify JWT externally and call isAuthorized() with the extracted principal"); + intern = Z_CEDAR_AUTHZ_CLIENT_P(ZEND_THIS); + if (Z_TYPE(intern->policy_store) != IS_OBJECT) { + zend_throw_exception_ex(cedar_ce_EvaluationException, 0, + "AuthorizationClient is not bound to a PolicyStore"); + return; + } + store = Z_CEDAR_POLICY_STORE_P(&intern->policy_store); + + /* identitySource must have been supplied to the constructor. */ + if (Z_TYPE(intern->identity_source) != IS_ARRAY) { + zend_throw_error(NULL, + "isAuthorizedWithToken(): AuthorizationClient was not " + "constructed with an 'identitySource' option"); + return; + } + id_src = Z_ARRVAL(intern->identity_source); + + /* Reject 'principal' from the caller — it is derived from the token. */ + if (zend_hash_str_exists(Z_ARRVAL_P(params), + "principal", sizeof("principal") - 1)) { + zend_throw_error(NULL, + "isAuthorizedWithToken(): 'principal' must not be supplied; " + "it is derived from the token"); + return; + } + + /* At least one of identityToken / accessToken (verified payload). */ + id_tok = zend_hash_str_find(Z_ARRVAL_P(params), + "identityToken", sizeof("identityToken") - 1); + ac_tok = zend_hash_str_find(Z_ARRVAL_P(params), + "accessToken", sizeof("accessToken") - 1); + if (id_tok && Z_TYPE_P(id_tok) == IS_ARRAY) { + payload = id_tok; + } else if (ac_tok && Z_TYPE_P(ac_tok) == IS_ARRAY) { + payload = ac_tok; + } else { + zend_throw_error(NULL, + "isAuthorizedWithToken(): 'identityToken' or 'accessToken' " + "(verified claims array) is required"); + return; + } + + /* Resolve principalEntityType + principalIdClaim (default "sub"). */ + p_type_zv = zend_hash_str_find(id_src, + "principalEntityType", sizeof("principalEntityType") - 1); + p_claim_zv = zend_hash_str_find(id_src, + "principalIdClaim", sizeof("principalIdClaim") - 1); + if (!p_type_zv || Z_TYPE_P(p_type_zv) != IS_STRING) { + zend_throw_error(NULL, + "isAuthorizedWithToken(): identitySource.principalEntityType " + "(string) is required"); + return; + } + if (p_claim_zv && Z_TYPE_P(p_claim_zv) == IS_STRING) { + p_claim_name = Z_STRVAL_P(p_claim_zv); + p_claim_len = Z_STRLEN_P(p_claim_zv); + } else { + p_claim_name = "sub"; + p_claim_len = sizeof("sub") - 1; + } + + p_type.data = (unsigned char *) Z_STRVAL_P(p_type_zv); + p_type.len = Z_STRLEN_P(p_type_zv); + + if (cedar_payload_str(payload, p_claim_name, p_claim_len, &p_id) + != PHP_CEDAR_OK) { + zend_throw_error(NULL, + "isAuthorizedWithToken(): claim '%s' is missing or not a " + "string in the supplied token payload", p_claim_name); + return; + } + + /* action / resource: same shape as isAuthorized(). */ + za = zend_hash_str_find(Z_ARRVAL_P(params), + "action", sizeof("action") - 1); + zr = zend_hash_str_find(Z_ARRVAL_P(params), + "resource", sizeof("resource") - 1); + if (cedar_pick_entity_ids(za, "actionType", sizeof("actionType") - 1, + "actionId", sizeof("actionId") - 1, + &a_type, &a_id) != PHP_CEDAR_OK + || cedar_pick_entity_ids(zr, "entityType", sizeof("entityType") - 1, + "entityId", sizeof("entityId") - 1, + &r_type, &r_id) != PHP_CEDAR_OK) { + zend_throw_error(NULL, + "isAuthorizedWithToken(): action must be " + "{actionType, actionId}, resource must be " + "{entityType, entityId}"); + return; + } + + /* Build group parents from identitySource.groupIdsClaim, if any. */ + array_init(&group_parents); + g_type_zv = zend_hash_str_find(id_src, + "groupEntityType", sizeof("groupEntityType") - 1); + g_claim_zv = zend_hash_str_find(id_src, + "groupIdsClaim", sizeof("groupIdsClaim") - 1); + if (g_type_zv && Z_TYPE_P(g_type_zv) == IS_STRING + && g_claim_zv && Z_TYPE_P(g_claim_zv) == IS_STRING) { + if (cedar_build_group_parents(payload, + Z_STRVAL_P(g_claim_zv), Z_STRLEN_P(g_claim_zv), + g_type_zv, &group_parents) != PHP_CEDAR_OK) { + zval_ptr_dtor(&group_parents); + zend_throw_error(NULL, + "isAuthorizedWithToken(): failed to map group claims"); + return; + } + } + + cedar_evaluate_request(store, params, + &p_type, &p_id, &a_type, &a_id, &r_type, &r_id, + &group_parents, + true, /* include extracted principal in response */ + return_value); + + zval_ptr_dtor(&group_parents); } /* ============================================================ diff --git a/cedar.stub.php b/cedar.stub.php index 4863f21..cd8c06b 100644 --- a/cedar.stub.php +++ b/cedar.stub.php @@ -32,10 +32,17 @@ public function policyIds(): array {} /** * Local evaluation client compatible with AVP's * Aws\VerifiedPermissions\VerifiedPermissionsClient. + * + * The optional $options array recognizes: + * - 'identitySource' (array): claim-mapper config used by + * isAuthorizedWithToken(). Keys: principalEntityType (string, + * required), principalIdClaim (string, default "sub"), + * groupEntityType (string, optional), groupIdsClaim (string, + * optional). */ final class AuthorizationClient { - public function __construct(PolicyStore $policyStore) {} + public function __construct(PolicyStore $policyStore, array $options = []) {} public function isAuthorized(array $params): array {} diff --git a/cedar_arginfo.h b/cedar_arginfo.h index 5c7d58f..d692fc3 100644 --- a/cedar_arginfo.h +++ b/cedar_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: c0a768b1135221393bb19d04c3001606665fb33e */ + * Stub hash: 59998e42413b019102dc8e5d7c6bc26b7f00c56c */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Cedar_PolicyStore___construct, 0, 0, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, policyStoreId, IS_STRING, 1, "null") @@ -23,6 +23,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Cedar_AuthorizationClient___construct, 0, 0, 1) ZEND_ARG_OBJ_INFO(0, policyStore, Cedar\\PolicyStore, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, options, IS_ARRAY, 0, "[]") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Cedar_AuthorizationClient_isAuthorized, 0, 1, IS_ARRAY, 0) diff --git a/tests/009-isauthorized-mismatch.phpt b/tests/009-isauthorized-mismatch.phpt index 696c941..9900ce6 100644 --- a/tests/009-isauthorized-mismatch.phpt +++ b/tests/009-isauthorized-mismatch.phpt @@ -31,4 +31,4 @@ try { ?> --EXPECT-- policyStoreId 'wrong-id' does not match the bound PolicyStore -isAuthorized(): 'policyStoreId' (string) is required +'policyStoreId' (string) is required diff --git a/tests/010-isauthorized-with-token-not-impl.phpt b/tests/010-isauthorized-with-token-not-impl.phpt deleted file mode 100644 index 16403d6..0000000 --- a/tests/010-isauthorized-with-token-not-impl.phpt +++ /dev/null @@ -1,22 +0,0 @@ ---TEST-- -AuthorizationClient::isAuthorizedWithToken: not yet implemented in the current release ---SKIPIF-- - ---FILE-- -isAuthorizedWithToken([ - "policyStoreId" => $store->id(), - "identityToken" => "eyJ...", - "action" => ["actionType" => "Action", "actionId" => "view"], - "resource" => ["entityType" => "Doc", "entityId" => "doc1"], - ]); -} catch (RuntimeException $e) { - echo $e->getMessage(), PHP_EOL; -} -?> ---EXPECTF-- -isAuthorizedWithToken() is not implemented yet;%a From 78e9da70ef73e0a39c532633df59628f954e6239 Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 11:40:33 +0900 Subject: [PATCH 08/19] test: cover isAuthorizedWithToken golden path and error surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new .phpt cases (full suite: 23/23 PASS): - 010: claim mapper derives principal from 'sub' and turns cognito:groups entries into MyApp::Group parents; principal appears in the response as { entityType, entityId } - 020: missing or non-matching group claim falls back to implicit DENY, but the response still echoes the extracted principal - 021: accessToken is used when identityToken is absent; identityToken takes precedence when both are supplied (matches AVP) - 022: error surfaces — no identitySource on the client, 'principal' supplied alongside the token, neither identityToken nor accessToken, missing principal claim, and policyStoreId mismatch - 023: principalIdClaim can be customized (e.g. "user_id" instead of "sub"); context.contextMap and entities.entityList still flow through alongside the derived principal --- tests/010-isauthorized-with-token-allow.phpt | 54 ++++++++++++++ tests/020-isauthorized-with-token-deny.phpt | 47 ++++++++++++ ...rized-with-token-accesstoken-fallback.phpt | 37 ++++++++++ tests/022-isauthorized-with-token-errors.phpt | 73 +++++++++++++++++++ ...-isauthorized-with-token-custom-claim.phpt | 35 +++++++++ 5 files changed, 246 insertions(+) create mode 100644 tests/010-isauthorized-with-token-allow.phpt create mode 100644 tests/020-isauthorized-with-token-deny.phpt create mode 100644 tests/021-isauthorized-with-token-accesstoken-fallback.phpt create mode 100644 tests/022-isauthorized-with-token-errors.phpt create mode 100644 tests/023-isauthorized-with-token-custom-claim.phpt diff --git a/tests/010-isauthorized-with-token-allow.phpt b/tests/010-isauthorized-with-token-allow.phpt new file mode 100644 index 0000000..faacb24 --- /dev/null +++ b/tests/010-isauthorized-with-token-allow.phpt @@ -0,0 +1,54 @@ +--TEST-- +AuthorizationClient::isAuthorizedWithToken: claim mapper derives principal and group parents -> ALLOW +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal in MyApp::Group::"admins", action, resource);'); + +$client = new Cedar\AuthorizationClient($store, [ + "identitySource" => [ + "principalEntityType" => "MyApp::User", + "principalIdClaim" => "sub", + "groupEntityType" => "MyApp::Group", + "groupIdsClaim" => "cognito:groups", + ], +]); + +$res = $client->isAuthorizedWithToken([ + "policyStoreId" => $store->id(), + "identityToken" => [ + "sub" => "alice-uuid", + "cognito:groups" => ["viewers", "admins"], + "token_use" => "id", + ], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +var_dump($res); +?> +--EXPECT-- +array(4) { + ["decision"]=> + string(5) "ALLOW" + ["determiningPolicies"]=> + array(1) { + [0]=> + array(1) { + ["policyId"]=> + string(2) "p1" + } + } + ["errors"]=> + array(0) { + } + ["principal"]=> + array(2) { + ["entityType"]=> + string(11) "MyApp::User" + ["entityId"]=> + string(10) "alice-uuid" + } +} diff --git a/tests/020-isauthorized-with-token-deny.phpt b/tests/020-isauthorized-with-token-deny.phpt new file mode 100644 index 0000000..5316ebf --- /dev/null +++ b/tests/020-isauthorized-with-token-deny.phpt @@ -0,0 +1,47 @@ +--TEST-- +AuthorizationClient::isAuthorizedWithToken: missing group claim falls back to implicit DENY +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal in MyApp::Group::"admins", action, resource);'); + +$client = new Cedar\AuthorizationClient($store, [ + "identitySource" => [ + "principalEntityType" => "MyApp::User", + "principalIdClaim" => "sub", + "groupEntityType" => "MyApp::Group", + "groupIdsClaim" => "cognito:groups", + ], +]); + +// User is not in admins +$res = $client->isAuthorizedWithToken([ + "policyStoreId" => $store->id(), + "identityToken" => [ + "sub" => "bob-uuid", + "cognito:groups" => ["viewers"], + ], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +echo $res["decision"], PHP_EOL; +echo $res["principal"]["entityId"], PHP_EOL; + +// No groups claim at all +$res = $client->isAuthorizedWithToken([ + "policyStoreId" => $store->id(), + "identityToken" => ["sub" => "carol-uuid"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +echo $res["decision"], PHP_EOL; +echo $res["principal"]["entityId"], PHP_EOL; +?> +--EXPECT-- +DENY +bob-uuid +DENY +carol-uuid diff --git a/tests/021-isauthorized-with-token-accesstoken-fallback.phpt b/tests/021-isauthorized-with-token-accesstoken-fallback.phpt new file mode 100644 index 0000000..2e6a9a5 --- /dev/null +++ b/tests/021-isauthorized-with-token-accesstoken-fallback.phpt @@ -0,0 +1,37 @@ +--TEST-- +AuthorizationClient::isAuthorizedWithToken: accessToken is used when identityToken is absent; identityToken wins when both are given +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal, action, resource);'); +$client = new Cedar\AuthorizationClient($store, [ + "identitySource" => [ + "principalEntityType" => "App::User", + "principalIdClaim" => "sub", + ], +]); + +// accessToken only +$res = $client->isAuthorizedWithToken([ + "policyStoreId" => $store->id(), + "accessToken" => ["sub" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +echo "accessToken only: ", $res["principal"]["entityId"], PHP_EOL; + +// identityToken wins when both are present +$res = $client->isAuthorizedWithToken([ + "policyStoreId" => $store->id(), + "identityToken" => ["sub" => "id-token-alice"], + "accessToken" => ["sub" => "access-token-alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +echo "both present: ", $res["principal"]["entityId"], PHP_EOL; +?> +--EXPECT-- +accessToken only: alice +both present: id-token-alice diff --git a/tests/022-isauthorized-with-token-errors.phpt b/tests/022-isauthorized-with-token-errors.phpt new file mode 100644 index 0000000..4ce16dc --- /dev/null +++ b/tests/022-isauthorized-with-token-errors.phpt @@ -0,0 +1,73 @@ +--TEST-- +AuthorizationClient::isAuthorizedWithToken: error paths (no identitySource, principal supplied, missing token, missing claim) +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal, action, resource);'); +$base_req = [ + "policyStoreId" => $store->id(), + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]; + +// 1) Client was constructed without identitySource +$plain = new Cedar\AuthorizationClient($store); +try { + $plain->isAuthorizedWithToken($base_req + ["identityToken" => ["sub" => "x"]]); +} catch (\Error $e) { + echo "1) ", $e->getMessage(), PHP_EOL; +} + +$client = new Cedar\AuthorizationClient($store, [ + "identitySource" => [ + "principalEntityType" => "App::User", + "principalIdClaim" => "sub", + ], +]); + +// 2) 'principal' must not be supplied +try { + $client->isAuthorizedWithToken($base_req + [ + "identityToken" => ["sub" => "x"], + "principal" => ["entityType" => "App::User", "entityId" => "x"], + ]); +} catch (\Error $e) { + echo "2) ", $e->getMessage(), PHP_EOL; +} + +// 3) neither identityToken nor accessToken +try { + $client->isAuthorizedWithToken($base_req); +} catch (\Error $e) { + echo "3) ", $e->getMessage(), PHP_EOL; +} + +// 4) principal claim missing from payload +try { + $client->isAuthorizedWithToken($base_req + [ + "identityToken" => ["email" => "a@example.com"], + ]); +} catch (\Error $e) { + echo "4) ", $e->getMessage(), PHP_EOL; +} + +// 5) policyStoreId mismatch still raises ResourceNotFoundException +try { + $client->isAuthorizedWithToken([ + "policyStoreId" => "wrong", + "identityToken" => ["sub" => "x"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + ]); +} catch (Cedar\Exception\ResourceNotFoundException $e) { + echo "5) ", $e->getMessage(), PHP_EOL; +} +?> +--EXPECT-- +1) isAuthorizedWithToken(): AuthorizationClient was not constructed with an 'identitySource' option +2) isAuthorizedWithToken(): 'principal' must not be supplied; it is derived from the token +3) isAuthorizedWithToken(): 'identityToken' or 'accessToken' (verified claims array) is required +4) isAuthorizedWithToken(): claim 'sub' is missing or not a string in the supplied token payload +5) policyStoreId 'wrong' does not match the bound PolicyStore diff --git a/tests/023-isauthorized-with-token-custom-claim.phpt b/tests/023-isauthorized-with-token-custom-claim.phpt new file mode 100644 index 0000000..1e46a0d --- /dev/null +++ b/tests/023-isauthorized-with-token-custom-claim.phpt @@ -0,0 +1,35 @@ +--TEST-- +AuthorizationClient::isAuthorizedWithToken: principalIdClaim can be customized, context/entities still flow through +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal, action, resource) when { principal.tier == "gold" && context.mfa == true };'); + +$client = new Cedar\AuthorizationClient($store, [ + "identitySource" => [ + "principalEntityType" => "App::User", + "principalIdClaim" => "user_id", // not the default 'sub' + ], +]); + +$res = $client->isAuthorizedWithToken([ + "policyStoreId" => $store->id(), + "identityToken" => ["user_id" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], + "context" => ["contextMap" => ["mfa" => ["boolean" => true]]], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "App::User", "entityId" => "alice"], + "attributes" => ["tier" => ["string" => "gold"]], + "parents" => [], + ]]], +]); +echo $res["decision"], PHP_EOL; +echo $res["principal"]["entityType"], "::", $res["principal"]["entityId"], PHP_EOL; +?> +--EXPECT-- +ALLOW +App::User::alice From d8bd2afb006d4a925c1515c43aa2db8184559664 Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 11:55:23 +0900 Subject: [PATCH 09/19] test: cover AVP PhotoFlash sample and additional Cedar syntax Add five .phpt cases driving the full suite to 28/28 PASS. - 030 mirrors the canonical AVP PhotoFlash sample: per-user permit, group-mediated permit, and the documented IsAuthorized request for alice / updatePhoto / VacationPhoto94.jpg with the photo nested in alice_folder. Covers principal-by-equality, principal-in-group via entities.parents, resource-in-album, the multi-policy determining list, and the negative cases for bob. - 031 covers the 'is' type-check (User vs Service principal) and 'is ... in' combined type + parent check against Group::"admins". - 032 covers the 'like' wildcard operator on resource.path with positive matches at the root and nested levels, plus the cases where the prefix exists without a trailing path or the path is in /private/. - 033 covers 'if ... then ... else' inside a when-clause, exercising both branches (admin role short-circuits true, otherwise the principal.tier check applies). - 034 covers @id / @advice annotations: the parser must accept them and the policy must still evaluate normally; the matched policyId shows the annotated bundle id in determiningPolicies. --- tests/030-avp-photoflash-sample.phpt | 111 +++++++++++++++++++++++++++ tests/031-syntax-is-and-is-in.phpt | 67 ++++++++++++++++ tests/032-syntax-like.phpt | 39 ++++++++++ tests/033-syntax-if-then-else.phpt | 45 +++++++++++ tests/034-syntax-annotations.phpt | 42 ++++++++++ 5 files changed, 304 insertions(+) create mode 100644 tests/030-avp-photoflash-sample.phpt create mode 100644 tests/031-syntax-is-and-is-in.phpt create mode 100644 tests/032-syntax-like.phpt create mode 100644 tests/033-syntax-if-then-else.phpt create mode 100644 tests/034-syntax-annotations.phpt diff --git a/tests/030-avp-photoflash-sample.phpt b/tests/030-avp-photoflash-sample.phpt new file mode 100644 index 0000000..3d3b638 --- /dev/null +++ b/tests/030-avp-photoflash-sample.phpt @@ -0,0 +1,111 @@ +--TEST-- +AVP PhotoFlash sample: per-user permit, group permit, and the canonical IsAuthorized example with parents +--SKIPIF-- + +--FILE-- +loadString("alice-view-vacationphoto", ' + permit ( + principal == PhotoFlash::User::"alice", + action == PhotoFlash::Action::"view", + resource == PhotoFlash::Photo::"VacationPhoto94.jpg" + ); + ') + ->loadString("alice-friends-view-vacationphoto", ' + permit ( + principal in PhotoFlash::UserGroup::"alice_friends", + action == PhotoFlash::Action::"view", + resource == PhotoFlash::Photo::"VacationPhoto94.jpg" + ); + ') + ->loadString("alice-updatephoto-in-folder", ' + permit ( + principal == PhotoFlash::User::"alice", + action == PhotoFlash::Action::"updatePhoto", + resource in PhotoFlash::Album::"alice_folder" + ); + '); + +$client = new Cedar\AuthorizationClient($store); +$view = [ + "policyStoreId" => "PSEXAMPLEabcdefg111111", + "action" => ["actionType" => "PhotoFlash::Action", "actionId" => "view"], + "resource" => ["entityType" => "PhotoFlash::Photo", "entityId" => "VacationPhoto94.jpg"], +]; + +// 1) alice viewing her own photo -> the per-user permit fires +$res = $client->isAuthorized($view + [ + "principal" => ["entityType" => "PhotoFlash::User", "entityId" => "alice"], +]); +echo "1 alice view: ", $res["decision"], " (", count($res["determiningPolicies"]), " policies)", PHP_EOL; + +// 2) bob has no matching policy +$res = $client->isAuthorized($view + [ + "principal" => ["entityType" => "PhotoFlash::User", "entityId" => "bob"], +]); +echo "2 bob view: ", $res["decision"], PHP_EOL; + +// 3) carol is a member of alice_friends -> the group permit fires +$res = $client->isAuthorized($view + [ + "principal" => ["entityType" => "PhotoFlash::User", "entityId" => "carol"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "PhotoFlash::User", "entityId" => "carol"], + "attributes" => [], + "parents" => [["entityType" => "PhotoFlash::UserGroup", "entityId" => "alice_friends"]], + ]]], +]); +echo "3 carol view: ", $res["decision"], PHP_EOL; + +// 4) The canonical AVP IsAuthorized request: alice updatePhoto +// VacationPhoto94.jpg where the photo lives inside alice_folder. +$res = $client->isAuthorized([ + "policyStoreId" => "PSEXAMPLEabcdefg111111", + "principal" => ["entityType" => "PhotoFlash::User", "entityId" => "alice"], + "action" => ["actionType" => "PhotoFlash::Action", "actionId" => "updatePhoto"], + "resource" => ["entityType" => "PhotoFlash::Photo", "entityId" => "VacationPhoto94.jpg"], + "entities" => ["entityList" => [ + [ + "identifier" => ["entityType" => "PhotoFlash::Photo", "entityId" => "VacationPhoto94.jpg"], + "attributes" => [], + "parents" => [["entityType" => "PhotoFlash::Album", "entityId" => "alice_folder"]], + ], + [ + "identifier" => ["entityType" => "PhotoFlash::Album", "entityId" => "alice_folder"], + "attributes" => [], + "parents" => [], + ], + ]], +]); +echo "4 alice update in folder: ", $res["decision"], PHP_EOL; +echo " matched: ", $res["determiningPolicies"][0]["policyId"] ?? "(none)", PHP_EOL; + +// 5) Same updatePhoto request but for bob -> DENY (no matching policy) +$res = $client->isAuthorized([ + "policyStoreId" => "PSEXAMPLEabcdefg111111", + "principal" => ["entityType" => "PhotoFlash::User", "entityId" => "bob"], + "action" => ["actionType" => "PhotoFlash::Action", "actionId" => "updatePhoto"], + "resource" => ["entityType" => "PhotoFlash::Photo", "entityId" => "VacationPhoto94.jpg"], + "entities" => ["entityList" => [ + [ + "identifier" => ["entityType" => "PhotoFlash::Photo", "entityId" => "VacationPhoto94.jpg"], + "attributes" => [], + "parents" => [["entityType" => "PhotoFlash::Album", "entityId" => "alice_folder"]], + ], + ]], +]); +echo "5 bob update in folder: ", $res["decision"], PHP_EOL; +?> +--EXPECT-- +1 alice view: ALLOW (1 policies) +2 bob view: DENY +3 carol view: ALLOW +4 alice update in folder: ALLOW + matched: alice-updatephoto-in-folder +5 bob update in folder: DENY diff --git a/tests/031-syntax-is-and-is-in.phpt b/tests/031-syntax-is-and-is-in.phpt new file mode 100644 index 0000000..17ea9ac --- /dev/null +++ b/tests/031-syntax-is-and-is-in.phpt @@ -0,0 +1,67 @@ +--TEST-- +Cedar syntax: 'is' type check and 'is ... in' combined type + parent check +--SKIPIF-- + +--FILE-- +loadString("p_is", 'permit(principal is User, action, resource);'); +$client = new Cedar\AuthorizationClient($store); +$base = [ + "policyStoreId" => "s", + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]; +echo "User: ", $client->isAuthorized($base + [ + "principal" => ["entityType" => "User", "entityId" => "a"], +])["decision"], PHP_EOL; +echo "Service: ", $client->isAuthorized($base + [ + "principal" => ["entityType" => "Service", "entityId" => "robot"], +])["decision"], PHP_EOL; + +// 'is ... in': require both type and group membership +$store2 = new Cedar\PolicyStore("s2"); +$store2->loadString("p_is_in", + 'permit(principal is User in Group::"admins", action, resource);'); +$c2 = new Cedar\AuthorizationClient($store2); + +$base2 = [ + "policyStoreId" => "s2", + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]; + +echo "User in admins: ", $c2->isAuthorized($base2 + [ + "principal" => ["entityType" => "User", "entityId" => "alice"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "alice"], + "attributes" => [], + "parents" => [["entityType" => "Group", "entityId" => "admins"]], + ]]], +])["decision"], PHP_EOL; + +echo "User in viewers: ", $c2->isAuthorized($base2 + [ + "principal" => ["entityType" => "User", "entityId" => "alice"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "alice"], + "attributes" => [], + "parents" => [["entityType" => "Group", "entityId" => "viewers"]], + ]]], +])["decision"], PHP_EOL; + +echo "Service in admins: ", $c2->isAuthorized($base2 + [ + "principal" => ["entityType" => "Service", "entityId" => "robot"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "Service", "entityId" => "robot"], + "attributes" => [], + "parents" => [["entityType" => "Group", "entityId" => "admins"]], + ]]], +])["decision"], PHP_EOL; +?> +--EXPECT-- +User: ALLOW +Service: DENY +User in admins: ALLOW +User in viewers: DENY +Service in admins: DENY diff --git a/tests/032-syntax-like.phpt b/tests/032-syntax-like.phpt new file mode 100644 index 0000000..3813dea --- /dev/null +++ b/tests/032-syntax-like.phpt @@ -0,0 +1,39 @@ +--TEST-- +Cedar syntax: 'like' operator with wildcard matching on string attributes +--SKIPIF-- + +--FILE-- +loadString("p_like", + 'permit(principal, action, resource) when { resource.path like "/public/*" };'); +$client = new Cedar\AuthorizationClient($store); + +$base = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]; +$entitiesWith = function(string $path) { + return ["entityList" => [[ + "identifier" => ["entityType" => "Doc", "entityId" => "doc1"], + "attributes" => ["path" => ["string" => $path]], + "parents" => [], + ]]]; +}; + +echo "/public/index.html: ", + $client->isAuthorized($base + ["entities" => $entitiesWith("/public/index.html")])["decision"], PHP_EOL; +echo "/public/sub/x.txt: ", + $client->isAuthorized($base + ["entities" => $entitiesWith("/public/sub/x.txt")])["decision"], PHP_EOL; +echo "/private/secret: ", + $client->isAuthorized($base + ["entities" => $entitiesWith("/private/secret")])["decision"], PHP_EOL; +echo "/public (no slash): ", + $client->isAuthorized($base + ["entities" => $entitiesWith("/public")])["decision"], PHP_EOL; +?> +--EXPECT-- +/public/index.html: ALLOW +/public/sub/x.txt: ALLOW +/private/secret: DENY +/public (no slash): DENY diff --git a/tests/033-syntax-if-then-else.phpt b/tests/033-syntax-if-then-else.phpt new file mode 100644 index 0000000..2bd6009 --- /dev/null +++ b/tests/033-syntax-if-then-else.phpt @@ -0,0 +1,45 @@ +--TEST-- +Cedar syntax: 'if ... then ... else' ternary expression in a when clause +--SKIPIF-- + +--FILE-- +loadString("p_if", + 'permit(principal, action, resource) when { + if context.role == "admin" then true else principal.tier == "gold" + };'); +$client = new Cedar\AuthorizationClient($store); + +$base = [ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]; +$with = function(?string $role, ?string $tier) use ($base) { + $req = $base; + if ($role !== null) { + $req["context"] = ["contextMap" => ["role" => ["string" => $role]]]; + } else { + $req["context"] = ["contextMap" => ["role" => ["string" => "guest"]]]; + } + $req["entities"] = ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "alice"], + "attributes" => ["tier" => ["string" => $tier ?? "none"]], + "parents" => [], + ]]]; + return $req; +}; + +// admin role -> "then true" branch +echo "admin / silver: ", ((new Cedar\AuthorizationClient($store))->isAuthorized($with("admin", "silver")))["decision"], PHP_EOL; +// non-admin + gold tier -> "else principal.tier == gold" is true +echo "guest / gold: ", $client->isAuthorized($with("guest", "gold"))["decision"], PHP_EOL; +// non-admin + non-gold -> false +echo "guest / silver: ", $client->isAuthorized($with("guest", "silver"))["decision"], PHP_EOL; +?> +--EXPECT-- +admin / silver: ALLOW +guest / gold: ALLOW +guest / silver: DENY diff --git a/tests/034-syntax-annotations.phpt b/tests/034-syntax-annotations.phpt new file mode 100644 index 0000000..efdb3e5 --- /dev/null +++ b/tests/034-syntax-annotations.phpt @@ -0,0 +1,42 @@ +--TEST-- +Cedar syntax: policy annotations are accepted at parse time and the policy still evaluates +--SKIPIF-- + +--FILE-- +loadString("annotated", ' + @id("policy-001") + @advice("contact security@example.com on denial") + permit ( + principal, + action == Action::"view", + resource + ); +'); + +$client = new Cedar\AuthorizationClient($store); +$res = $client->isAuthorized([ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "view"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +echo $res["decision"], PHP_EOL; +echo $res["determiningPolicies"][0]["policyId"], PHP_EOL; + +// A policy with annotations but a non-matching action -> DENY +$res = $client->isAuthorized([ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "alice"], + "action" => ["actionType" => "Action", "actionId" => "delete"], + "resource" => ["entityType" => "Doc", "entityId" => "doc1"], +]); +echo $res["decision"], PHP_EOL; +?> +--EXPECT-- +ALLOW +annotated +DENY From 21fb00e1a825b38c4263b87096353e812d2687a7 Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 12:04:13 +0900 Subject: [PATCH 10/19] docs: add README, MIT LICENSE, PIE manifest, and CI workflow Prepare the repository for distribution via PIE (the modern PHP extension installer replacing PECL). README.md - Quick-start example and full API reference for Cedar\\PolicyStore and Cedar\\AuthorizationClient (including the identitySource option used by isAuthorizedWithToken). - Mapping table for AVP compatibility and the AttributeValue Union. - Explicit "unsupported features" list inherited from the bundled Cedar evaluator (datetime / duration, entity tags, policy templates, schema validation, dynamic identity sources). - Performance/persistence note pointing at APCu / opcache.preload as the interim sharing story, plus a forward reference to a future pemalloc-based persistent variant. - "One PolicyStore per AuthorizationClient" caveat with a roadmap pointer for multi-store support. - "Token verification is caller's responsibility" section explaining why isAuthorizedWithToken takes a verified claims array rather than a raw JWT string, with a firebase/php-jwt example. - ZTS status section recording the static review and the pending --enable-zts verification. LICENSE - MIT for the extension code itself. - The bundled src/cedar/ snapshot keeps its upstream license; the LICENSE file points at src/cedar/UPSTREAM.md. composer.json (PIE manifest) - type: "php-ext" with extension-name "cedar", priority 80. - support-zts: false / support-nts: true (conservative until ZTS is verified end-to-end). - require php ^8.4 because php_random_bytes_silent moved into ext/random/ in PHP 8.4 and the source uses that header directly. .github/workflows/ci.yml - Matrix over {PHP 8.4, 8.5} x {NTS, ZTS} on ubuntu-latest using shivammathur/setup-php with phpts env. - Runs phpize -> ./configure --enable-cedar -> make -j2 -> make test with NO_INTERACTION / REPORT_EXIT_STATUS so a single failing .phpt fails the job. - ZTS rows are marked continue-on-error: true (informational) until the ZTS support roadmap item is closed; NTS rows must pass. - Failed runs upload the .phpt diff/log/out artifacts so a regression can be inspected without re-running locally. --- .github/workflows/ci.yml | 70 ++++++++ LICENSE | 26 +++ README.md | 350 +++++++++++++++++++++++++++++++++++++++ composer.json | 36 ++++ 4 files changed, 482 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3024bfe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-and-test: + name: PHP ${{ matrix.php-version }} (${{ matrix.ts }}) + runs-on: ubuntu-latest + # ZTS support is on the roadmap; keep ZTS rows informational only + # so the matrix surfaces results without blocking PRs. + continue-on-error: ${{ matrix.ts == 'zts' }} + strategy: + fail-fast: false + matrix: + php-version: ['8.4', '8.5'] + ts: [nts, zts] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + set-safe-directory: true + token: ${{ github.token }} + + - name: Setup PHP (${{ matrix.php-version }} ${{ matrix.ts }}) + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + with: + php-version: ${{ matrix.php-version }} + extensions: none + coverage: none + ini-values: zend.assertions=1 + env: + phpts: ${{ matrix.ts }} + + - name: Show PHP build info + run: | + php -v + php -r 'echo "ZTS: ", PHP_ZTS ? "yes" : "no", PHP_EOL;' + php-config --configure-options | tr ' ' '\n' | grep -i zts || true + + - name: phpize + run: phpize + + - name: configure + run: ./configure --enable-cedar + + - name: make + run: make -j2 + + - name: make test + run: | + # NO_INTERACTION=1 keeps run-tests.php non-interactive on + # failure; REPORT_EXIT_STATUS=1 makes a single failed test + # fail the job. + NO_INTERACTION=1 REPORT_EXIT_STATUS=1 make test + + - name: Upload failed test diffs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: phpt-failures-${{ matrix.php-version }}-${{ matrix.ts }} + path: | + tests/**/*.diff + tests/**/*.log + tests/**/*.out + if-no-files-found: ignore diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29d971b --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2026 Tatsuya Kamijo +Copyright (c) 2026 Bengo4.com, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +The bundled Cedar evaluator sources under `src/cedar/` (excluding +`php_cedar_compat.h`, `php_cedar_compat.c`, and `UPSTREAM.md`) are a snapshot +of nxe-cedar (https://github.com/kjdev/nxe-cedar). They retain their original +license from upstream; see `src/cedar/UPSTREAM.md` for the snapshot commit and +re-import policy. diff --git a/README.md b/README.md new file mode 100644 index 0000000..59e4674 --- /dev/null +++ b/README.md @@ -0,0 +1,350 @@ +# PHP Cedar Extension + +A PHP extension that evaluates [Cedar](https://www.cedarpolicy.com/) policies +locally, with an API compatible with +[Amazon Verified Permissions (AVP)](https://aws.amazon.com/verified-permissions/) +(`Aws\VerifiedPermissions\VerifiedPermissionsClient`). Swap an AVP client +for `Cedar\AuthorizationClient` in your code and the request / response +payloads keep the same shape — there is no AVP service call, the policies +are evaluated in-process. + +The Cedar evaluation engine is a snapshot of +[nxe-cedar](https://github.com/kjdev/nxe-cedar) (the NGINX-edge Cedar +evaluator) rewritten for use inside a PHP extension. See +[`src/cedar/UPSTREAM.md`](src/cedar/UPSTREAM.md) for the upstream commit +and re-import policy. + +## Requirements + +- PHP **8.4 or later** (NTS). PHP 8.4 introduced the `ext/random/` + reorganization that this extension depends on for CSPRNG-backed + `PolicyStore` id generation. +- A POSIX build environment (`phpize`, `make`, a C compiler). +- ZTS is **not** supported in the current release (see + [ZTS status](#zts-status) below). + +## Installation + +### With PIE (recommended) + +[PIE](https://github.com/php/pie) is the modern PHP extension installer +(it replaces `pecl install`). + +```bash +pie install kjdev/cedar +``` + +PIE drives the standard `phpize` → `configure` → `make` → +`make install` flow under the hood, taking care of locating +`php-config` and dropping a `cedar.ini` into the right SAPI directory. + +### Manual build + +```bash +phpize +./configure --enable-cedar +make +make test +make install # may need elevated privileges +``` + +Then enable the extension by adding `extension=cedar.so` to a PHP ini +file (e.g. `/etc/php.d/40-cedar.ini`). + +## Quick start + +```php +loadString('admin-may-view', <<<'CEDAR' +permit ( + principal in MyApp::Group::"admins", + action == MyApp::Action::"view", + resource +); +CEDAR); + +$client = new Cedar\AuthorizationClient($store); + +$result = $client->isAuthorized([ + 'policyStoreId' => 'my-app-store', + 'principal' => ['entityType' => 'MyApp::User', 'entityId' => 'alice'], + 'action' => ['actionType' => 'MyApp::Action', 'actionId' => 'view'], + 'resource' => ['entityType' => 'MyApp::Doc', 'entityId' => 'doc-42'], + 'entities' => ['entityList' => [[ + 'identifier' => ['entityType' => 'MyApp::User', 'entityId' => 'alice'], + 'attributes' => [], + 'parents' => [['entityType' => 'MyApp::Group', 'entityId' => 'admins']], + ]]], +]); + +// $result === [ +// 'decision' => 'ALLOW', +// 'determiningPolicies' => [['policyId' => 'admin-may-view']], +// 'errors' => [], +// ] +``` + +## API overview + +### `Cedar\PolicyStore` + +Container that holds one or more Cedar policy bundles. It corresponds +to the **PolicyStore** concept in AVP. + +| Method | Description | +| --- | --- | +| `__construct(?string $policyStoreId = null)` | Optional explicit id; auto-generated 32-char lowercase hex when omitted. | +| `loadFile(string $policyId, string $path): static` | Read a policy file via `php_stream` and register it under `$policyId`. Throws `Cedar\Exception\PolicyParseException` on parse errors or duplicate ids. | +| `loadString(string $policyId, string $cedarText): static` | Same as `loadFile` but takes the source directly. Returns `$this` (fluent). | +| `id(): string` | Returns the configured policy store id. | +| `policyIds(): list` | Returns the ids of every bundle currently loaded. | + +### `Cedar\AuthorizationClient` + +AVP-compatible evaluation client. + +```php +new Cedar\AuthorizationClient(PolicyStore $store, array $options = []); +``` + +`$options['identitySource']` (required only for +`isAuthorizedWithToken()`): + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `principalEntityType` | string | _(required)_ | Cedar entity type used for the derived principal, e.g. `"MyApp::User"`. | +| `principalIdClaim` | string | `"sub"` | Claim that holds the principal id inside the verified token payload. | +| `groupEntityType` | string | _(optional)_ | Cedar entity type for groups (e.g. `"MyApp::Group"`). | +| `groupIdsClaim` | string | _(optional)_ | Claim that holds the principal's group ids as a list of strings (e.g. `"cognito:groups"`). | + +Methods: + +| Method | Description | +| --- | --- | +| `isAuthorized(array $params): array` | AVP-shaped evaluation against the bound `PolicyStore`. | +| `isAuthorizedWithToken(array $params): array` | Like `isAuthorized()` but the principal (and optional group parents) come from a token payload; see [Token verification](#token-verification-is-callers-responsibility) below. | + +### Request shape (AVP-compatible) + +Both methods accept the same keys as the corresponding AVP API: + +```php +[ + 'policyStoreId' => string, // must equal $store->id() + 'principal' => ['entityType' => ..., 'entityId' => ...], // isAuthorized only + 'action' => ['actionType' => ..., 'actionId' => ...], + 'resource' => ['entityType' => ..., 'entityId' => ...], + 'context' => ['contextMap' => [name => AttributeValue, ...]], + 'entities' => ['entityList' => [ + [ + 'identifier' => ['entityType' => ..., 'entityId' => ...], + 'attributes' => [name => AttributeValue, ...], + 'parents' => [['entityType' => ..., 'entityId' => ...], ...], + ], + ... + ]], + // isAuthorizedWithToken only: + 'identityToken' => [claim => value, ...], // verified claims array + 'accessToken' => [claim => value, ...], // verified claims array +] +``` + +`AttributeValue` is the same single-key union AVP uses: + +```php +['string' => 'admin'] +['long' => 42] +['boolean' => true] +['ipaddr' => '10.0.0.1'] +['decimal' => '12.3400'] +['entityIdentifier' => ['entityType' => 'MyApp::User', 'entityId' => 'bob']] +['set' => [AttributeValue, ...]] +['record' => [name => AttributeValue, ...]] +``` + +### Response shape + +```php +[ + 'decision' => 'ALLOW' | 'DENY', + 'determiningPolicies' => [['policyId' => string], ...], + 'errors' => [['errorDescription' => string], ...], + // isAuthorizedWithToken only: + 'principal' => ['entityType' => string, 'entityId' => string], +] +``` + +### Exceptions + +All three subclass `\RuntimeException`: + +| Class | When | +| --- | --- | +| `Cedar\Exception\PolicyParseException` | Cedar syntax error or duplicate `policyId` in `loadFile` / `loadString`. | +| `Cedar\Exception\EvaluationException` | Allocation / engine-level failures. | +| `Cedar\Exception\ResourceNotFoundException` | `policyStoreId` in the request does not match `PolicyStore::id()`. AVP raises the same-named error in this case. | + +Argument-shape errors (missing `policyStoreId`, supplying `principal` +to `isAuthorizedWithToken`, etc.) surface as the engine-level `Error` +class, not as one of the Cedar exceptions. + +## AVP compatibility + +| Aspect | Compatibility | +| --- | --- | +| `isAuthorized()` request keys | **Complete** (`policyStoreId / principal / action / resource / context / entities`). | +| `AttributeValue` union members | **Complete** for everything the bundled Cedar evaluator supports (see [Unsupported features](#unsupported-features) for the upstream gaps). | +| Response keys | `decision / determiningPolicies / errors` match exactly. `Aws\Result`'s `ArrayAccess` methods (`->get(...)`) are **not** available — the response is a plain array. | +| `isAuthorizedWithToken()` | API-shape compatible (`policyStoreId / identityToken / accessToken / action / resource / context / entities`, response includes the derived `principal`), but **the token is expected to be a verified claims array, not a raw JWT string**. See [Token verification](#token-verification-is-callers-responsibility). | + +A typical migration path is to introduce a thin interface in your +application code that both `Aws\VerifiedPermissions\VerifiedPermissionsClient` +and `Cedar\AuthorizationClient` can satisfy: + +```php +interface AuthorizationClientInterface { + public function isAuthorized(array $params): array; + public function isAuthorizedWithToken(array $params): array; +} +``` + +then swap the concrete implementation via dependency injection. + +## Token verification is caller's responsibility + +`isAuthorizedWithToken()` accepts the token as a **decoded, verified +claims array** rather than as a raw JWT string. This is a deliberate +design choice: + +- The extension does not bundle a JWT verifier; it would force + OpenSSL / json parsing dependencies onto the build. +- Accepting a string would invite a class of bugs where the JWT is + decoded without checking the signature, allowing an attacker to + forge principals. + +Use a dedicated PHP library — for example +[`firebase/php-jwt`](https://github.com/firebase/php-jwt) or +[`web-token/jwt-framework`](https://github.com/web-token/jwt-framework) — +to verify the JWT (signature, issuer, expiry, audience, `token_use`) +and pass the resulting payload array to this extension: + +```php +$payload = $jwtVerifier->decode($rawJwt); // verified by your library + +$client = new Cedar\AuthorizationClient($store, [ + 'identitySource' => [ + 'principalEntityType' => 'MyApp::User', + 'principalIdClaim' => 'sub', + 'groupEntityType' => 'MyApp::Group', + 'groupIdsClaim' => 'cognito:groups', + ], +]); + +$result = $client->isAuthorizedWithToken([ + 'policyStoreId' => $store->id(), + 'identityToken' => $payload, + 'action' => ['actionType' => 'MyApp::Action', 'actionId' => 'view'], + 'resource' => ['entityType' => 'MyApp::Doc', 'entityId' => 'doc-42'], +]); +``` + +When both `identityToken` and `accessToken` are supplied, +`identityToken` wins — same behavior as AVP. + +## Unsupported features + +The Cedar evaluator follows the feature set bundled from upstream +nxe-cedar. The following features are **not** available in this +release: + +- `datetime` / `duration` `AttributeValue` types and their methods + (`<.`, `≤.`, `≥.`, `>.`, `toDate`, ...). Pass them as `long` (Unix + timestamps) and use `<`, `<=`, `>=`, `>` instead. +- Entity tags (`.hasTag()` / `.getTag()`). +- Policy templates (`?principal`, `?resource`) and template-linked + policies. +- Schema validation (`@anyOf`, `@oneOf`, declared attributes). +- Dynamic identity sources: AVP can validate a JWT against a Cognito + user pool or generic OIDC IdP and derive the principal. This + extension delegates that to the caller (see + [Token verification](#token-verification-is-callers-responsibility)). + +A malformed or unsupported `AttributeValue` (for example +`['datetime' => '...']`) does not abort the request: the entry is +skipped and an entry is appended to the response's `errors[]`. This +matches AVP's behavior of returning a successful response with +populated `errors` when a single attribute is broken. + +## Performance and persistence + +The current implementation is **request-scoped**: every PHP-FPM +request that needs to authorize re-parses the policy bundles via +`PolicyStore::loadFile()` / `loadString()`. Parsing is fast for the +small / medium policy sets typical of authorization rules (the +Cedar grammar is small and the evaluator is hand-written C with no +external dependencies), so this is normally not a hot spot. + +If you need to share parsed policies across requests within a worker: + +- For now, cache the **policy text** in [APCu](https://www.php.net/apcu) + or [opcache.preload](https://www.php.net/manual/en/opcache.preloading.php) + and rebuild the `PolicyStore` once per request from the cached + string. Parsing is still cheap. +- A future release may add a persistent (`pemalloc`) policy store + variant that survives `RSHUTDOWN`. This is tracked as a known + follow-up; the request-scoped API will remain the default. + +## Multiple PolicyStores per client + +This release supports **one `PolicyStore` per `AuthorizationClient`**. +If you need to evaluate requests against different stores (e.g. one +store per tenant), instantiate a separate `AuthorizationClient` for +each one. A future release may accept multiple stores on the +constructor and dispatch on the request's `policyStoreId`. + +## ZTS status + +`composer.json` reports `support-zts: false`. The current release has +been verified to build and run only on **NTS** PHP. Code review of +the C sources shows: + +- No `ZEND_BEGIN_MODULE_GLOBALS` / `TSRMLS_CACHE_*` usage; the + extension has no request-scoped module globals. +- File-scope `static` state (class entries, object handlers) is + written exactly once in `MINIT` and read-only thereafter, which is + safe under ZTS. +- Per-request data lives on PHP objects (`zend_object` + internal + structs) whose lifecycle is already thread-isolated by Zend. + +A full `--enable-zts` build + `make test` run is on the roadmap; until +then the manifest is conservative. + +## Developing the extension + +```bash +phpize +./configure --enable-cedar +make +make test +``` + +`cedar.stub.php` declares the PHP-facing API. After editing it, +regenerate `cedar_arginfo.h`: + +```bash +php /usr/lib64/php/build/gen_stub.php cedar.stub.php +``` + +The test suite uses the standard `.phpt` format under `tests/`. Run +a subset with: + +```bash +make test TESTS=tests/030-avp-photoflash-sample.phpt +``` + +## License + +This extension is released under the [MIT License](LICENSE). The +bundled Cedar evaluator under `src/cedar/` retains its upstream +license; see [`src/cedar/UPSTREAM.md`](src/cedar/UPSTREAM.md). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e2ece42 --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "kjdev/cedar", + "description": "PHP extension that evaluates Cedar policies locally with an Amazon Verified Permissions (AVP) compatible API.", + "type": "php-ext", + "license": "MIT", + "keywords": [ + "cedar", + "authorization", + "policy", + "verified-permissions", + "avp", + "rbac", + "abac" + ], + "homepage": "https://github.com/kjdev/php-ext-cedar", + "authors": [ + { + "name": "kjdev", + "homepage": "https://github.com/kjdev" + } + ], + "require": { + "php": "^8.4" + }, + "php-ext": { + "extension-name": "cedar", + "priority": 80, + "support-zts": false, + "support-nts": true, + "configure-options": [] + }, + "support": { + "issues": "https://github.com/kjdev/php-ext-cedar/issues", + "source": "https://github.com/kjdev/php-ext-cedar" + } +} From 34d74cce188b8cf8a6b2d699dee4f723426a0c30 Mon Sep 17 00:00:00 2001 From: kjdev Date: Wed, 27 May 2026 12:40:29 +0900 Subject: [PATCH 11/19] docs: add AVP drop-in replacement section to README Show how to swap Cedar\AuthorizationClient and the AVP SDK behind a single interface via dependency injection. The new section appears right after Quick start to surface AVP compatibility as the headline value proposition, with an adapter pattern that coerces Aws\Result to a plain array on the AVP side. --- README.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 94 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 59e4674..e1ce7aa 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,97 @@ $result = $client->isAuthorized([ // ] ``` +## Drop-in replacement for AWS Verified Permissions + +`Cedar\AuthorizationClient::isAuthorized()` accepts the same request shape +as `Aws\VerifiedPermissions\VerifiedPermissionsClient::isAuthorized()` and +returns a response with the same top-level keys (`decision`, +`determiningPolicies`, `errors`). You can therefore swap one for the other +through dependency injection — a common setup is **AVP in production, +this extension for local development, CI, and on-prem deployments**, with +no changes at the call site. + +Introduce a thin interface that both implementations satisfy, and wrap +each concrete client in a small adapter. The adapter on the AVP side +exists only to coerce `Aws\Result` (which implements `ArrayAccess`) into +a plain array so the return type matches: + +```php +interface AuthorizationClientInterface +{ + public function isAuthorized(array $params): array; + public function isAuthorizedWithToken(array $params): array; +} + +final class CedarLocalClient implements AuthorizationClientInterface +{ + public function __construct(private readonly \Cedar\AuthorizationClient $client) {} + + public function isAuthorized(array $params): array + { + return $this->client->isAuthorized($params); + } + + public function isAuthorizedWithToken(array $params): array + { + return $this->client->isAuthorizedWithToken($params); + } +} + +final class AvpClient implements AuthorizationClientInterface +{ + public function __construct( + private readonly \Aws\VerifiedPermissions\VerifiedPermissionsClient $client, + ) {} + + public function isAuthorized(array $params): array + { + return $this->client->isAuthorized($params)->toArray(); + } + + public function isAuthorizedWithToken(array $params): array + { + return $this->client->isAuthorizedWithToken($params)->toArray(); + } +} +``` + +Wire either implementation into your container, then keep the call site +identical: + +```php +$authorizer = getenv('APP_ENV') === 'production' + ? new AvpClient(new \Aws\VerifiedPermissions\VerifiedPermissionsClient([ + 'region' => 'us-east-1', + 'version' => 'latest', + ])) + : new CedarLocalClient(new \Cedar\AuthorizationClient($store)); + +$result = $authorizer->isAuthorized([ + 'policyStoreId' => $policyStoreId, + 'principal' => ['entityType' => 'MyApp::User', 'entityId' => 'alice'], + 'action' => ['actionType' => 'MyApp::Action', 'actionId' => 'view'], + 'resource' => ['entityType' => 'MyApp::Doc', 'entityId' => 'doc-42'], + 'entities' => ['entityList' => [/* ... */]], +]); +// $result['decision'] === 'ALLOW' | 'DENY' +``` + +The `policyStoreId` value is the only thing that differs between the two +backends: + +- With AVP, it is the `PS...` id returned by `CreatePolicyStore`. +- With this extension, it is the string you passed to (or that was + auto-generated by) `Cedar\PolicyStore::__construct()`. + +Inject the right value through configuration (env var, parameter, etc.) +and the rest of the request payload is byte-for-byte identical. + +See [AVP compatibility](#avp-compatibility) for the per-key compatibility +matrix and [Unsupported features](#unsupported-features) for the known +gaps (`datetime` / `duration`, entity tags, policy templates, schema +validation, dynamic identity sources). + ## API overview ### `Cedar\PolicyStore` @@ -198,18 +289,9 @@ class, not as one of the Cedar exceptions. | Response keys | `decision / determiningPolicies / errors` match exactly. `Aws\Result`'s `ArrayAccess` methods (`->get(...)`) are **not** available — the response is a plain array. | | `isAuthorizedWithToken()` | API-shape compatible (`policyStoreId / identityToken / accessToken / action / resource / context / entities`, response includes the derived `principal`), but **the token is expected to be a verified claims array, not a raw JWT string**. See [Token verification](#token-verification-is-callers-responsibility). | -A typical migration path is to introduce a thin interface in your -application code that both `Aws\VerifiedPermissions\VerifiedPermissionsClient` -and `Cedar\AuthorizationClient` can satisfy: - -```php -interface AuthorizationClientInterface { - public function isAuthorized(array $params): array; - public function isAuthorizedWithToken(array $params): array; -} -``` - -then swap the concrete implementation via dependency injection. +For a worked example of swapping AVP and this extension behind a single +interface, see +[Drop-in replacement for AWS Verified Permissions](#drop-in-replacement-for-aws-verified-permissions). ## Token verification is caller's responsibility From 6e4c7aeb2d6dab19458bb3fff270a4b4ca48c2d3 Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 06:56:42 +0900 Subject: [PATCH 12/19] fix: define TSRMLS cache symbol for ZTS DSO builds The shared module lacked ZEND_TSRMLS_CACHE_EXTERN/DEFINE/UPDATE, so on ZTS builds the DSO had no _tsrm_ls_cache definition. Loading cedar.so failed with "undefined symbol: _tsrm_ls_cache" and every test was skipped as the extension never loaded. Add the three standard ext_skel macros, guarded by ZTS && COMPILE_DL_CEDAR so NTS builds are unaffected. --- cedar.c | 7 +++++++ php_cedar.h | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/cedar.c b/cedar.c index b621bf2..3aefa71 100644 --- a/cedar.c +++ b/cedar.c @@ -1251,6 +1251,10 @@ PHP_METHOD(Cedar_AuthorizationClient, isAuthorizedWithToken) PHP_MINIT_FUNCTION(cedar) { +#if defined(ZTS) && defined(COMPILE_DL_CEDAR) + ZEND_TSRMLS_CACHE_UPDATE(); +#endif + /* PolicyStore */ cedar_ce_PolicyStore = register_class_Cedar_PolicyStore(); cedar_ce_PolicyStore->create_object = cedar_policy_store_create; @@ -1305,5 +1309,8 @@ zend_module_entry cedar_module_entry = { }; #ifdef COMPILE_DL_CEDAR +#ifdef ZTS +ZEND_TSRMLS_CACHE_DEFINE() +#endif ZEND_GET_MODULE(cedar) #endif diff --git a/php_cedar.h b/php_cedar.h index 417c689..2e2514a 100644 --- a/php_cedar.h +++ b/php_cedar.h @@ -25,4 +25,8 @@ extern zend_module_entry cedar_module_entry; # include "TSRM.h" #endif +#if defined(ZTS) && defined(COMPILE_DL_CEDAR) +ZEND_TSRMLS_CACHE_EXTERN() +#endif + #endif /* PHP_CEDAR_H */ From 9ebd785b00b1562f515eaffea4f6c1592d906068 Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 07:14:39 +0900 Subject: [PATCH 13/19] test: cover loadFile failure when the policy file cannot be opened --- tests/025-policy-store-load-file-missing.phpt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/025-policy-store-load-file-missing.phpt diff --git a/tests/025-policy-store-load-file-missing.phpt b/tests/025-policy-store-load-file-missing.phpt new file mode 100644 index 0000000..b683639 --- /dev/null +++ b/tests/025-policy-store-load-file-missing.phpt @@ -0,0 +1,19 @@ +--TEST-- +PolicyStore: loadFile throws PolicyParseException when the file cannot be opened +--SKIPIF-- + +--FILE-- +loadFile("p1", $path); + echo "no exception\n"; +} catch (Cedar\Exception\PolicyParseException $e) { + echo get_class($e), "\n"; + echo $e->getMessage(), "\n"; +} +?> +--EXPECTF-- +Cedar\Exception\PolicyParseException +failed to open cedar policy file "%s" From c7d9a962b6fd520465b17a962531176f10adfe36 Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 07:16:29 +0900 Subject: [PATCH 14/19] test: cover strict long/boolean AttributeValue rejection --- ...26-isauthorized-attr-strict-primitive.phpt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/026-isauthorized-attr-strict-primitive.phpt diff --git a/tests/026-isauthorized-attr-strict-primitive.phpt b/tests/026-isauthorized-attr-strict-primitive.phpt new file mode 100644 index 0000000..48841b2 --- /dev/null +++ b/tests/026-isauthorized-attr-strict-primitive.phpt @@ -0,0 +1,28 @@ +--TEST-- +AuthorizationClient::isAuthorized: long/boolean AttributeValue rejects type-mismatched values without silent coercion +--SKIPIF-- + +--FILE-- +loadString("p1", 'permit(principal, action, resource);'); +$client = new Cedar\AuthorizationClient($store); + +$res = $client->isAuthorized([ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "a"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "Doc", "entityId" => "d"], + "context" => ["contextMap" => [ + "okLong" => ["long" => 5], + "badLong" => ["long" => 1.9], + "okBool" => ["boolean" => true], + "badBool" => ["boolean" => "false"], + ]], +]); +echo $res["decision"], PHP_EOL; +echo "errors=", count($res["errors"]), PHP_EOL; +?> +--EXPECT-- +ALLOW +errors=2 From 894a8810e470a013afbe0b1ee289b85b45ac217d Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 07:18:34 +0900 Subject: [PATCH 15/19] test: cover entity that is both principal and resource --- ...ized-entity-principal-equals-resource.phpt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/027-isauthorized-entity-principal-equals-resource.phpt diff --git a/tests/027-isauthorized-entity-principal-equals-resource.phpt b/tests/027-isauthorized-entity-principal-equals-resource.phpt new file mode 100644 index 0000000..8406fab --- /dev/null +++ b/tests/027-isauthorized-entity-principal-equals-resource.phpt @@ -0,0 +1,29 @@ +--TEST-- +AuthorizationClient::isAuthorized: an entity that is both principal and resource gets attributes and parents on both targets +--SKIPIF-- + +--FILE-- +loadString("p1", + 'permit(principal, action, resource) when { principal.team == resource.team };'); +$client = new Cedar\AuthorizationClient($store); + +// principal and resource are the very same entity. Its attributes must be +// applied to both targets, otherwise resource.team would be undefined. +$res = $client->isAuthorized([ + "policyStoreId" => "s", + "principal" => ["entityType" => "User", "entityId" => "u1"], + "action" => ["actionType" => "Action", "actionId" => "x"], + "resource" => ["entityType" => "User", "entityId" => "u1"], + "entities" => ["entityList" => [[ + "identifier" => ["entityType" => "User", "entityId" => "u1"], + "attributes" => ["team" => ["string" => "blue"]], + ]]], +]); +echo $res["decision"], PHP_EOL; +echo "errors=", count($res["errors"]), PHP_EOL; +?> +--EXPECT-- +ALLOW +errors=0 From 6dddca78e434cbb967153d02dbb3d4179cd4b6d7 Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 07:21:08 +0900 Subject: [PATCH 16/19] test: use a unique temp file with guaranteed cleanup in loadFile test --- tests/005-policy-store-load-file.phpt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/005-policy-store-load-file.phpt b/tests/005-policy-store-load-file.phpt index 592121a..74cbb99 100644 --- a/tests/005-policy-store-load-file.phpt +++ b/tests/005-policy-store-load-file.phpt @@ -4,14 +4,16 @@ PolicyStore: loadFile reads a policy from disk --FILE-- loadFile("p1", $path); -var_dump($store->policyIds()); - -unlink($path); +try { + $store = new Cedar\PolicyStore(); + $store->loadFile("p1", $path); + var_dump($store->policyIds()); +} finally { + @unlink($path); +} ?> --EXPECT-- array(1) { From 0233c4bca6bbe7bfd361a65b7d71afb2859ce933 Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 10:21:13 +0900 Subject: [PATCH 17/19] fix: make the policy store id fallback counter atomic for ZTS safety --- cedar.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cedar.c b/cedar.c index 3aefa71..30b2b47 100644 --- a/cedar.c +++ b/cedar.c @@ -10,6 +10,7 @@ #endif #include +#include #include "php.h" #include "ext/standard/info.h" @@ -104,10 +105,11 @@ cedar_generate_policy_store_id(void) if (php_random_bytes_silent(raw, sizeof(raw)) == FAILURE) { /* Fallback (only when the CSPRNG fails) still fills the 16-byte - * buffer, so the output keeps the same 32-char hex shape. */ - static uint64_t seq = 0; + * buffer, so the output keeps the same 32-char hex shape. The + * counter is atomic so ids stay unique across threads under ZTS. */ + static atomic_uint_least64_t seq; uint64_t t = (uint64_t) time(NULL); - uint64_t s = ++seq; + uint64_t s = (uint64_t) atomic_fetch_add(&seq, 1) + 1; memcpy(raw, &t, sizeof(t)); memcpy(raw + sizeof(t), &s, sizeof(s)); } From f9d0cf5364e006adab8460677ec7616faa81e014 Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 10:21:14 +0900 Subject: [PATCH 18/19] chore: declare ZTS as a supported build in the manifest and CI --- .github/workflows/ci.yml | 3 --- composer.json | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3024bfe..a99052a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,6 @@ jobs: build-and-test: name: PHP ${{ matrix.php-version }} (${{ matrix.ts }}) runs-on: ubuntu-latest - # ZTS support is on the roadmap; keep ZTS rows informational only - # so the matrix surfaces results without blocking PRs. - continue-on-error: ${{ matrix.ts == 'zts' }} strategy: fail-fast: false matrix: diff --git a/composer.json b/composer.json index e2ece42..12a029d 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "php-ext": { "extension-name": "cedar", "priority": 80, - "support-zts": false, + "support-zts": true, "support-nts": true, "configure-options": [] }, From d39fb3dceaa3a641923bf23ab13d218827173ad2 Mon Sep 17 00:00:00 2001 From: kjdev Date: Fri, 29 May 2026 10:21:14 +0900 Subject: [PATCH 19/19] docs: mark ZTS as supported in the ZTS status section --- README.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e1ce7aa..3323fa5 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ and re-import policy. ## Requirements -- PHP **8.4 or later** (NTS). PHP 8.4 introduced the `ext/random/` +- PHP **8.4 or later** (NTS or ZTS). PHP 8.4 introduced the `ext/random/` reorganization that this extension depends on for CSPRNG-backed `PolicyStore` id generation. - A POSIX build environment (`phpize`, `make`, a C compiler). -- ZTS is **not** supported in the current release (see +- Both NTS and ZTS builds are supported (see [ZTS status](#zts-status) below). ## Installation @@ -387,21 +387,22 @@ constructor and dispatch on the request's `policyStoreId`. ## ZTS status -`composer.json` reports `support-zts: false`. The current release has -been verified to build and run only on **NTS** PHP. Code review of -the C sources shows: - -- No `ZEND_BEGIN_MODULE_GLOBALS` / `TSRMLS_CACHE_*` usage; the - extension has no request-scoped module globals. -- File-scope `static` state (class entries, object handlers) is - written exactly once in `MINIT` and read-only thereafter, which is - safe under ZTS. +Both **NTS** and **ZTS** builds are supported. `composer.json` reports +`support-zts: true`, and CI runs a full `--enable-zts` build plus +`make test` for every PHP version in the matrix. Thread-safety notes: + +- The extension has no request-scoped module globals + (`ZEND_BEGIN_MODULE_GLOBALS`). For ZTS DSO builds it only defines the + TSRMLS cache symbol (`ZEND_TSRMLS_CACHE_DEFINE` / `EXTERN`) that the + Zend ABI requires. +- File-scope `static` state (class entries, object handlers) is written + exactly once in `MINIT` and read-only thereafter. +- The one mutable file-scope counter — the fallback in + `cedar_generate_policy_store_id` used only when the CSPRNG fails — is + an atomic counter, so ids stay unique across threads. - Per-request data lives on PHP objects (`zend_object` + internal structs) whose lifecycle is already thread-isolated by Zend. -A full `--enable-zts` build + `make test` run is on the roadmap; until -then the manifest is conservative. - ## Developing the extension ```bash