|null */ function ci_app_access_object(mixed $value): ?array { if ($value instanceof stdClass) return get_object_vars($value); if (is_array($value) && !array_is_list($value)) return $value; return null; } function ci_app_access_nonempty_string(mixed $value): bool { // Python's str.strip() also treats Unicode separators and U+001C..U+001F // as whitespace. Check emptiness only; never normalize the actual subject. return is_string($value) && preg_match('/[\x00-\x1f\x7f]/', $value) === 0 && preg_match('/[^\s\p{Z}\x{001c}-\x{001f}]/u', $value) === 1; } /** @return array{allowed:bool,effective_role:?string,reason:string,policy_revision:?int} */ function ci_app_access_decision(bool $allowed, ?string $role, string $reason, ?int $revision): array { return ['allowed' => $allowed, 'effective_role' => $role, 'reason' => $reason, 'policy_revision' => $revision]; } /** * Evaluate one policy against a trusted, currently verified server context. * * Inputs may be decoded JSON objects or associative PHP arrays. An empty JSON * object must remain an object; lists are not interpreted as object maps. * * @return array{allowed:bool,effective_role:?string,reason:string,policy_revision:?int} */ function ci_app_access_evaluate(mixed $policy, mixed $context): array { $ranks = ['guest' => 0, 'customer' => 1, 'developer' => 2, 'admin' => 3]; $roles = array_keys($ranks); $p = ci_app_access_object($policy); $invalidPolicy = ci_app_access_decision(false, null, 'invalid_policy', null); if ($p === null || array_diff(array_keys($p), ['schema', 'app_id', 'revision', 'min_role', 'max_role', 'allowed_users', 'user_max_roles'])) return $invalidPolicy; $rawRevision = $p['revision'] ?? null; if (($p['schema'] ?? null) !== 'ci-app-access-policy/v1' || !ci_app_access_nonempty_string($p['app_id'] ?? null) || (!is_int($rawRevision) && !is_float($rawRevision)) || !is_finite((float)$rawRevision) || $rawRevision < 1 || $rawRevision > 9007199254740991 || floor((float)$rawRevision) !== (float)$rawRevision) return $invalidPolicy; foreach (['min_role', 'max_role'] as $key) { if (array_key_exists($key, $p) && (!is_string($p[$key]) || !isset($ranks[$p[$key]]))) return $invalidPolicy; } if (isset($p['min_role'], $p['max_role']) && $ranks[$p['min_role']] > $ranks[$p['max_role']]) return $invalidPolicy; if (array_key_exists('allowed_users', $p)) { if (!is_array($p['allowed_users']) || !array_is_list($p['allowed_users'])) return $invalidPolicy; $seen = []; foreach ($p['allowed_users'] as $subject) { if (!ci_app_access_nonempty_string($subject) || in_array($subject, $seen, true)) return $invalidPolicy; $seen[] = $subject; } } $userMaxRoles = []; if (array_key_exists('user_max_roles', $p)) { $userMaxRoles = ci_app_access_object($p['user_max_roles']); if ($userMaxRoles === null) return $invalidPolicy; foreach ($userMaxRoles as $subject => $role) { if (!ci_app_access_nonempty_string((string)$subject) || !is_string($role) || !isset($ranks[$role])) return $invalidPolicy; } } $revision = (int)$rawRevision; $deny = static fn(string $reason): array => ci_app_access_decision(false, null, $reason, $revision); $c = ci_app_access_object($context); $required = ['subject', 'active', 'authenticated', 'view_role', 'account_max_role', 'binding_valid']; $keys = [...$required, 'window_role', 'connection_max_role', 'guest_access', 'legacy_roles', 'legacy_denied', 'owner_allowed']; if ($c === null || array_diff(array_keys($c), $keys) || array_diff($required, array_keys($c))) return $deny('invalid_context'); foreach (['active', 'authenticated', 'binding_valid', 'guest_access', 'legacy_denied', 'owner_allowed'] as $key) { if (array_key_exists($key, $c) && !is_bool($c[$key])) return $deny('invalid_context'); } foreach (['view_role', 'account_max_role'] as $key) { if (!is_string($c[$key]) || !isset($ranks[$c[$key]])) return $deny('invalid_context'); } foreach (['window_role', 'connection_max_role'] as $key) { if (array_key_exists($key, $c) && $c[$key] !== null && (!is_string($c[$key]) || !isset($ranks[$c[$key]]))) return $deny('invalid_context'); } if (isset($c['legacy_roles'])) { if (!is_array($c['legacy_roles']) || !array_is_list($c['legacy_roles'])) return $deny('invalid_context'); $seen = []; foreach ($c['legacy_roles'] as $role) { if (!is_string($role) || !isset($ranks[$role]) || in_array($role, $seen, true)) return $deny('invalid_context'); $seen[] = $role; } } if ($c['authenticated']) { if (!ci_app_access_nonempty_string($c['subject'])) return $deny('invalid_context'); } elseif ($c['subject'] !== null || $c['view_role'] !== 'guest' || $c['account_max_role'] !== 'guest' || (($c['window_role'] ?? 'guest') !== 'guest')) { return $deny('invalid_context'); } if ($c['authenticated'] && !$c['active']) return $deny('inactive_account'); if (!$c['binding_valid']) return $deny('binding_invalid'); if (($c['owner_allowed'] ?? true) !== true) return $deny('owner_denied'); if (($c['legacy_denied'] ?? false) === true) return $deny('legacy_denied'); if (isset($p['allowed_users']) && !in_array($c['subject'], $p['allowed_users'], true)) return $deny('user_not_allowed'); $basis = $c['window_role'] ?? $c['view_role']; $rank = min($ranks[$basis], $ranks[$c['account_max_role']]); if (isset($p['max_role'])) $rank = min($rank, $ranks[$p['max_role']]); if (isset($c['connection_max_role'])) $rank = min($rank, $ranks[$c['connection_max_role']]); if ($c['subject'] !== null && array_key_exists($c['subject'], $userMaxRoles)) { $rank = min($rank, $ranks[$userMaxRoles[$c['subject']]]); } $effective = $roles[$rank]; if ((!$c['authenticated'] || $basis === 'guest' || $effective === 'guest') && ($c['guest_access'] ?? false) !== true) return $deny('guest_denied'); if (isset($c['legacy_roles']) && !in_array($effective, $c['legacy_roles'], true)) return $deny('role_not_granted'); if (isset($p['min_role']) && $rank < $ranks[$p['min_role']]) return $deny('minimum_not_met'); return ci_app_access_decision(true, $effective, 'allowed', $revision); } // One JSON object on stdin: {"policy":{...},"context":{...}}. No filesystem, // network or account changes. Requiring this file never starts the CLI runner. if (PHP_SAPI === 'cli' && isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { try { $input = json_decode(stream_get_contents(STDIN), false, 64, JSON_THROW_ON_ERROR); if (!$input instanceof stdClass || !property_exists($input, 'policy') || !property_exists($input, 'context') || array_diff(array_keys(get_object_vars($input)), ['policy', 'context'])) { $result = ci_app_access_decision(false, null, 'invalid_context', null); } else { $result = ci_app_access_evaluate($input->policy, $input->context); } } catch (JsonException) { $result = ci_app_access_decision(false, null, 'invalid_context', null); } echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PHP_EOL; }