Enterprise Middleware

RBAC / ABAC Policy Middleware

Route-level authorisation for your Kipchak API from roles, permissions and attribute conditions in config, or delegated to Open Policy Agent.

Introduction

Note: This middleware is only available with Kipchak Enterprise.

Authentication tells you who a request is from. This middleware decides what they may do, from one config file, so that no controller has to remember to check, and a route added next year is denied until a rule allows it.

  • Roles (RBAC): roles come from the verified token (roles, Keycloak's realm_access.roles, identity provider groups mapped to roles) or from the consumer that Consumer Identity names, so API key and signed-request clients have roles too. Roles inherit from each other and grant permissions such as orders:refund, with wildcards (orders:*, *). OAuth scopes can be permissions directly.
  • Attributes (ABAC): conditions compare the subject, the route's arguments, the request, the time and the resource: a caller only reaches their own tenant's data, an admin route only from the internal network.
  • Open Policy Agent: any rule can hand the decision to OPA, through the OPA driver, for policies kept and versioned outside the API.
  • Deny by default: a request no rule covers is refused.
  • Checks in code: for decisions that depend on what the route loads, such as whether this order belongs to this customer, Policy::authorise() uses the same rules and conditions.
  • Recorded: every denial is logged with its reason and, with the Audit Log, noted on the audit record.

Installation

To install this middleware, you need access to the Enterprise Composer repository at https://php.pkgs.1x.ax/.

composer require kipchak/middleware-policy

Copy the sample config to config/kipchak.policy.php and initialise the middleware in middlewares/middlewares.php, before the Audit Log if you use it:

use Kipchak\Middleware\Policy\Policy;

Error::initialise($app);
Policy::initialise($app);
Audit::initialise($app);
JWT::initialise($app);     // or JWKS, Key, HMAC

The rules are checked where each route runs, after every middleware, so the token has been verified whether authentication is global or attached to the route, and the route's arguments are known. With the Audit Log, use kipchak/middleware-audit 1.2 or later; Composer enforces it.

Configuration

<?php

return [
    'enabled' => true,
    'default' => 'deny',        // requests no rule covers: 'deny' or 'allow'
    'deny_status' => 403,       // or 404, to hide routes; anonymous requests always get 401

    'roles_from' => [
        ['source' => 'token', 'claim' => 'roles'],
        ['source' => 'token', 'claim' => 'realm_access.roles'],
        ['source' => 'token', 'claim' => 'groups', 'map' => ['g-platform-ops' => 'admin']],
        ['source' => 'consumer', 'map' => ['support-console' => ['support']]],
    ],
    'permissions_from' => [
        ['source' => 'token', 'claim' => 'scope'],
    ],

    'roles' => [
        'admin' => ['inherits' => ['editor', 'support'], 'permissions' => ['*']],
        'editor' => ['inherits' => ['viewer'], 'permissions' => ['orders:write', 'orders:refund']],
        'support' => ['inherits' => ['viewer'], 'permissions' => ['customers:read']],
        'viewer' => ['permissions' => ['orders:read']],
    ],

    'conditions' => [
        'same-tenant' => [['subject.tenant', 'eq', '$args.tenant']],
        'internal-network' => [['request.ip', 'matches', '/^10\./']],
    ],

    'rules' => [
        ['name' => 'public', 'paths' => ['/health', '/openapi.yaml'], 'allow' => 'anyone'],
        ['name' => 'read orders', 'methods' => ['GET'], 'routes' => ['/v1/orders', '/v1/orders/{id}'], 'permission' => 'orders:read'],
        ['name' => 'refunds', 'methods' => ['POST'], 'routes' => ['/v1/orders/{id}/refund'], 'permission' => 'orders:refund'],
        ['name' => 'tenant data', 'routes' => ['/v1/tenants/{tenant}/*'], 'allow' => 'authenticated', 'when' => 'same-tenant'],
        ['name' => 'admin', 'paths' => ['/v1/admin', '/v1/admin/*'], 'role' => 'admin', 'when' => 'internal-network'],
        ['name' => 'reports', 'paths' => ['/v1/reports/*'], 'opa' => 'httpapi/reports/allow'],
    ],

    'opa' => [
        'connection' => 'default',
        'on_error' => 'closed',
        'cache_ttl' => 0,
        'headers' => [],
    ],

    'log_denials' => 'info',
];

The config is checked when the worker starts: an unknown setting, a role inheriting in a cycle, a condition referring to something that does not exist or an invalid regular expression stops the worker with a message naming the setting.

Rules

The first rule that covers a request decides. A rule covers a request by:

SettingMatches
methodsHTTP methods. Without it, every method.
routesRoute patterns as written in routes/, such as /v1/orders/{id}/refund.
pathsRequest paths, such as /v1/admin/users.

In routes and paths, * matches anything, slashes included, so /v1/admin/* covers /v1/admin/users/7 but not /v1/admin itself; list both. A rule with neither routes nor paths covers every request of its methods.

What it requires, every part given having to hold:

SettingRequires
allowanyone (public), authenticated (any verified caller) or nobody.
roleOne of these roles.
permissionAll of these permissions.
whenAn attribute condition, or the name of one in conditions.
opaAn allow from the OPA rule at this path.

Who the subject is

The consumer and tenant are Consumer Identity's, from config/kipchak.identity.php; set consumer or tenant in this config to override them for the policy only. A request is authenticated when it carries a token that auth-jwt or auth-jwks verified, or when Consumer Identity names its consumer.

Roles and permissions come from these sources, each adding what it finds:

SourceOptionsReads
tokenclaim, mapA claim of the verified token. Dots reach into nested claims (realm_access.roles); a namespaced claim such as https://example.com/roles is found as a whole.
consumermap (required)Roles for the consumer Consumer Identity names: API key clients, signed-request key IDs, a gateway's header.
tenantmap (required)Roles for everyone in a tenant.
attributename, mapA request attribute an earlier middleware set.
headername, mapA request header. Only for a header your gateway sets and overwrites.
callableresolve, mapfn (ServerRequestInterface $request): array|string|null.

A value may be a list or a string separated by spaces or commas, as OAuth scopes are. With a map, each name is translated to one or more names and names not in the map are ignored, which is how identity provider groups become roles without trusting every group.

Roles and permissions

A role grants its own permissions and those of every role it inherits, resolved once when the worker starts. A permission ending in * grants every permission beginning with what comes before it: orders:* grants orders:read and orders:refund:partial, and * grants everything. A role the subject has but the config does not define still counts for role checks and grants nothing.

Conditions

A condition compares a reference with a value:

['subject.tenant', 'eq', '$args.tenant']        // the right side is a reference when it starts with $
['subject.claims.email_verified', 'eq', true]
['request.ip', 'matches', '/^10\./']
['resource.owner', 'exists']

Combine them with a list (all must hold), ['any' => [...]], ['all' => [...]] and ['not' => ...], refer to a named one by its name, or write your own as fn (Context $context): bool.

ReferenceIs
subject.consumer, subject.tenantThe Consumer Identity consumer and tenant.
subject.roles, subject.permissionsEvery role and permission, inherited ones included.
subject.authenticatedWhether the request is authenticated.
subject.claims.<path>A claim of the verified token.
args.<name>A route argument, such as args.id for /v1/orders/{id}.
route.pattern, route.nameThe matched route.
request.method, request.path, request.ipThe request. request.ip is the connecting address.
request.query.<name>, request.header.<name>, request.body.<path>Query parameters, headers and the parsed body.
resource.<path>What a controller passed to Policy::authorise().
time.hour, time.weekday, time.date, time.unixThe time in UTC; weekday 1 is Monday.
OperatorHolds when
eq, neEqual, not equal. Numbers and strings compare by value ("42" equals 42); true only equals true.
in, not_inThe value is, or is not, in a list.
containsA list contains the value, or a string contains the text.
intersectsTwo lists share an element.
gt, gte, lt, lteNumeric comparisons.
matchesA regular expression matches.
exists, missingThe reference has a value, or has none.

A reference to something that is not there makes every comparison false except missing, so a condition that cannot be evaluated denies.

Routes

To add a requirement to a route or group, on top of the global rules, attach Authorise:

use Kipchak\Middleware\Policy\Handlers\Authorise;

$app->get('/v1/admin/stats', [Admin::class, 'stats'])->add(new Authorise(role: 'admin'));
$app->get('/v1/tenants/{tenant}/invoices', [Invoices::class, 'list'])->add(new Authorise(when: 'same-tenant'));
$app->get('/v1/reports/{id}', [Reports::class, 'show'])->add(new Authorise(opa: 'httpapi/reports/allow'));

With authentication attached to the same route, add Authorise first, as Slim runs the last-added middleware first.

Checks in code

Some decisions need what the route loads. Policy::authorise() checks a requirement against the request, with the resource's attributes under resource., and throws AccessDenied, which the Error middleware answers with 401, 403, 404 or 503:

use Kipchak\Middleware\Policy\Policy;

$order = $this->orders->find($args['id']);

Policy::authorise($request, when: ['any' => [
    ['subject.consumer', 'eq', '$resource.customer'],
    ['subject.roles', 'contains', 'support'],
]], resource: ['customer' => $order->customerId]);

Policy::allows() takes the same arguments and returns a boolean. The subject is on the request too:

use Kipchak\Middleware\Policy\Subject;

$subject = Subject::of($request);
$subject?->can('orders:refund');
$subject?->hasRole('support');

Open Policy Agent

A rule with opa asks OPA through the OPA driver (initialise it in drivers/drivers.php with the connection named in opa.connection). The rule receives:

{
  "subject": { "consumer": "acme", "tenant": "7", "authenticated": true, "roles": ["analyst"], "permissions": [], "claims": { "sub": "u1" } },
  "action": { "method": "GET", "permissions": [] },
  "resource": { "route": "/v1/tenants/{tenant}/reports", "route_name": null, "args": { "tenant": "7" } },
  "request": { "method": "GET", "path": "/v1/tenants/7/reports", "query": {}, "ip": "203.0.113.9", "headers": {} }
}

It allows the request when its result is true, or an object whose allow is true; an object's reason is logged with a denial. Anything else, including an undefined result, denies:

package httpapi.reports

import rego.v1

decision := {"allow": true} if {
	input.request.method == "GET"
	"analyst" in input.subject.roles
	input.subject.tenant == input.resource.args.tenant
}

decision := {"allow": false, "reason": "reports are read-only"} if input.request.method != "GET"

When OPA cannot be reached or fails, on_error decides: closed (the default) answers 503, open allows. An outage is logged once every 10 seconds, with the number of decisions it affected, not once per request. With the Circuit Breaker guarding OPA's host, requests fail in milliseconds instead of waiting for OPA's timeout. Requests that need no OPA decision carry on either way. cache_ttl keeps each decision in the worker for that many seconds, keyed by rule and input, and headers lists request headers to send.

Responses and logs

StatusWhen
401The request is not authenticated and a rule needs it to be, or any rule denies it. Authenticating may help.
403An authenticated request is denied. 404 with 'deny_status' => 404.
503OPA could not decide and on_error is closed.

Responses say only that the request was denied. The reason (needs the permission orders:refund, a condition does not hold, OPA httpapi/reports/allow denied: reports are read-only) goes to the log at log_denials level, as Request denied by policy, with the rule's name, the consumer, the tenant, the method and the path. With the Audit Log, the record of a denied request has outcome: denied, the verified consumer and details.policy with the rule and reason.

Limits

  • Rules are checked once the route is matched, so a request for a route that does not exist gets 404 and one with the wrong method 405, as without the middleware.
  • request.ip is the connecting address. Behind a proxy, compare a header your proxy sets, or use Subashi Pro to restrict networks.
  • A CLI script booted with Api::bootCli() loads no routes or middlewares. Policy::allows() still works there on a request you build.

Performance

A decision from config takes microseconds: the subject is built once per request and rules, roles and conditions are compiled when the worker starts. In testing on the Kipchak Enterprise runtime in worker mode, with the Enterprise JWT middleware, the Audit Log and OPA, 30,000 concurrent requests from seven kinds of caller each got the status the policy should give them, at about 4,000 requests a second on a laptop, with no subject carried over between requests and memory steady. With OPA paused, OPA-backed routes answered 503 after the connection timeout while the others carried on, and recovered on the first request after OPA returned.

Git Repository

The source code for this middleware is available on 1x.ax at https://1x.ax/mamluk/enterprise/kipchak/middlewares/policy.

Previous
Audit Log