[TT-16132] Granular access control conditions on query parameters, headers and body - #8652
Conversation
🎯 Recommended Merge TargetsBased on JIRA ticket TT-16132: Conditional Granular Access Control for keys and policies Fix Version: Tyk 5.16.0
Required:
📋 Workflow
|
|
This pull request introduces a significant enhancement to Tyk's authorization capabilities by enabling granular access control conditions based on request headers, query parameters, and the request body. This allows for more sophisticated security policies, such as restricting access to an endpoint based on the presence or absence of a specific parameter. Key changes include:
Files Changed AnalysisThe changes are spread across several key areas of the gateway:
Architecture & Impact Assessment
Request Flow with Granular Access Conditionsgraph TD
A[Request In] --> B(Auth Middleware);
B --> C{Session Normalized};
C --> D(GranularAccessMiddleware);
D --> E{URL & Method Match?};
E -- Yes --> F{Access Conditions Exist?};
E -- No --> G[403 Forbidden];
F -- No --> H[200 OK / Allowed];
F -- Yes --> I(Evaluate Conditions on Headers, Query, Body);
I -- All Match --> H;
I -- Any Fail --> G;
Scope Discovery & Context Expansion
Metadata
Powered by Visor from Probelabs Last updated: 2026-09-07T12:04:38.093Z | Triggered by: pr_updated | Commit: 7cdee18 💡 TIP: You can chat with Visor using |
Security Issues (1)
Architecture Issues (1)
Performance Issues (2)
Security Issues (1)
Quality Issues (1)
Powered by Visor from Probelabs Last updated: 2026-09-07T12:04:14.776Z | Triggered by: pr_updated | Commit: 7cdee18 💡 TIP: You can chat with Visor using |
6582bd6 to
393191a
Compare
Access conditions reused the URL Rewrite trigger matcher, but the two want
opposite defaults. A rewrite trigger decides whether to apply a transformation,
so leaning towards firing is harmless; an access condition decides whether to
let a request through, so it has to lean the other way.
Borrowing that matcher meant a policy could not express the rule the customer
actually needs, and three ways to get more access than the policy granted:
- "parameter must be absent" was inexpressible. Reverse was only evaluated
when the parameter was present, so a public policy written with it also
rejected the legitimate bare request.
- matchers were counted per matching value rather than per configured name,
so repeating one parameter satisfied a rule that required a different one.
- only one supplied value had to match, so an extra value on a repeated
parameter went unchecked.
- an uncompilable reversed pattern granted access instead of denying it.
Evaluate conditions in a dedicated evaluator instead, with absence
expressible, every supplied value checked, matchers counted per name, and
anything unevaluable denied. mw_url_rewrite.go is left alone so existing
rewrite triggers are unaffected. Evaluation no longer records matches in the
request context data either, so it cannot disturb the trigger numbering a
later URL Rewrite relies on.
The same fixes apply to the header, path part, session meta and request
context matchers, which shared the defects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds on the spike PoC (TT-17881) and closes the gaps that stood between it and a shippable feature. Validation. Access conditions fail closed, so a condition the Gateway cannot evaluate denies every request the entry would otherwise grant. user.AccessCondition.Validate reports those cases - an unreadable match_rx, a condition configuring no options, an unrecognised "on" - and the key and policy APIs reject them at write time. The rules live in the user package so the Dashboard can apply exactly the same ones when it accepts a key or policy, rather than the two drifting apart on what counts as valid. CoProcess bridge. The Protocol Buffer session carried only the URL and the methods, so a custom auth plugin's session came back without its conditions - a spec that loses its conditions grants strictly more than the one that went out. AccessSpec now carries them in both directions. While there: TykSessionState allocated the allowed URL slice by length and then appended to it, padding every access definition with as many zero-valued specs as it had real ones. An empty URL is an empty regex, which matches every path, so those padding entries handed a custom-auth session the whole API regardless of what its allowed_urls said. This predates conditions and applies to any gRPC, Python or Lua custom auth plugin that returns allowed_urls. copyAllowedURLs dropped conditions too. It has no production caller today, but a copy that silently widens access is a trap for whoever wires it up next. Precedence. A session holding both an unconditional and a conditional entry for one endpoint is granted access by the unconditional one: allowed_urls is a list of grants and grants are additive, so combining policies can never tighten what another policy already allowed. Pinned by a test in both orderings rather than left to the reader. Absence. "This parameter must not be supplied" is spelled as an empty match_rx with reverse set - the pattern says nothing about the value, so reversing it can only be about presence. ".*" with reverse is equivalent and still works. The evaluator borrows apidef.RoutingTriggerOptions as its configuration shape, which couples an access decision to a type maintained for URL Rewrite. An option the evaluator does not know about would be accepted, appear to constrain the request, and then be ignored - so a test now fails if that type gains a field, rather than the drift surfacing as access somebody thought they had restricted. Also removes initTriggerOptions and initStringRegexMaps, left behind when the evaluator stopped reusing the URL Rewrite matcher.
Option groups were all evaluated even when the outcome was already decided. Under "all" that meant a request whose header or query condition had already failed still had its entire body read into memory for a payload match it could not benefit from. Groups are ordered cheapest first with the payload last, so short circuiting is what makes that ordering worth anything. Semantics are unchanged - under "all" one unsatisfied group is the whole answer, and under "any" the first satisfied one already returned early. Reading the payload is unbounded, as it is in the URL Rewrite trigger this borrows its configuration shape from, and the Gateway only caps request bodies when max_request_body_size is set, which it is not by default. Not reading the body at all when it cannot change the outcome is the cheap half of that; a dedicated inspection limit would be a config decision rather than a fix. Also adds a benchmark for MergeAllowedURLs. Specs without conditions are keyed by URL alone and never reach the JSON encoding, so existing deployments pay nothing; specs with conditions cost roughly 0.6us each.
SonarCloud flagged three issues in new code, all in test files: mw_granular_access_test.go had third-party imports sitting in the standard library group, and two http.NewRequest calls discarded their error.
SonarCloud flagged conditionMatch (21) and checkTriggerOptions (38) against a limit of 15. Both had grown the same way: six near-identical blocks, one per option group, each repeating the counting and short-circuiting logic inline. Both now describe their groups as data and share one loop over them, so the counting rules are stated once instead of six times. The two loops differ in one respect, which is now stated rather than implied. conditionMatch stops as soon as the answer is settled, because its last group reads the whole request body. checkTriggerOptions deliberately does not: its checks record their matches in the request context data for rewrite templates to interpolate, so skipping the remaining options after one fails would change what a rewrite can reference. Also drops the lazily parsed query cache in conditionMatch. Only one group ever read it, so it was never saving a second parse. Visor separately reported a nil pointer dereference on the request context matches. Reading from a nil map is defined in Go and yields the zero value, so there is no panic - a request with no context data simply supplies no values. Added a test for it: a plain rule cannot be satisfied, a reversed one is, which is what access control needs.
948b275 to
d41283f
Compare
|
SentinelOne CNS Hardcoded Secret Detector SentinelOne CNS is a cloud-agnostic, agentless CSPM & CWPP solution that continuously detects and prevents vulnerabilities that have the highest probability of being exploited in Azure, AWS, Google Cloud, and Kubernetes. |
|



Description
Takes the TT-17881 spike PoC to something shippable. The first two commits are the PoC as it stands on #8623 (draft); the third is the production work.
Conditions are validated where they are accepted. Access conditions fail closed — a condition the Gateway cannot evaluate denies every request the entry would otherwise have granted.
user.AccessCondition.Validatereports those cases (an unreadablematch_rx, a condition that configures no options, an unrecognisedon), and the key and policy APIs reject them at write time. The rules live in theuserpackage rather than in the Gateway so the Dashboard can apply exactly the same ones, instead of the two drifting apart on what counts as valid.The CoProcess bridge carries conditions. The Protocol Buffer session carried only the URL and the methods, so a custom auth plugin's session came back without its conditions. A spec that loses its conditions grants strictly more than the one that went out, so this is a correctness problem rather than a missing feature.
AccessSpecnow carries them in both directions.🚨 A pre-existing bug fixed while in there.
TykSessionStateallocated the allowed-URL slice by length and then appended to it, padding every access definition with as many zero-valued specs as it had real ones. An empty URL is an empty regex, which matches every path — so those padding entries handed a custom-auth session the whole API, regardless of what itsallowed_urlssaid. This predates conditions entirely and affects any gRPC, Python or Lua custom auth plugin that returnsallowed_urls. It is fixed and covered by a test here, but it deserves its own ticket and a look from whoever owns security response; it should not ship quietly inside a feature PR.copyAllowedURLsdropped conditions too. It has no production caller today, but a copy that silently widens access is a trap for whoever wires it up next.Two questions the refinement left open, answered here
How a condition says "this parameter must not be supplied." An empty
match_rxplaces no constraint on the value, so reversing it can only be about presence:{ "query_val_matches": { "persnbr": { "reverse": true } } }No new API surface, and the PoC's
{"match_rx": ".*", "reverse": true}still works. Happy to revisit if reviewers prefer an explicit field — but it has to be settled before release, since customers will write policies against whatever ships.An unconditional and a conditional entry for the same endpoint. The unconditional one grants access.
allowed_urlsis a list of grants and grants are additive, so combining policies can never tighten what another policy already allowed. Pinned by a test in both orderings rather than left to the reader.Notes for review
apidef.RoutingTriggerOptionsas its configuration shape, which couples an access decision to a type maintained for URL Rewrite. An option the evaluator did not know about would be accepted, appear to constrain the request, and then be ignored — access somebody thought they had restricted. A test now fails if that type gains a field.checkTriggerOptions(commit 1) is a behaviour-preserving extraction frommw_url_rewrite.go. It is no longer needed by this feature, since the evaluator is standalone. Happy to split it into its own PR to keep the security-relevant change small — say the word.coprocess_session_state.pb.gohad struct fields its own descriptor did not declare, so the checked-in bindings had drifted from the.proto. Regenerating made it consistent, which accounts for most of that diff.Related Issue
https://tyktech.atlassian.net/browse/TT-16132 (spike: https://tyktech.atlassian.net/browse/TT-17881)
Supersedes the draft PoC in #8623 — that one can be closed once this is reviewed.
Companion Dashboard PR: TykTechnologies/tyk-analytics#6170
Dashboard UI is tracked separately in TT-18101.
Motivation and Context
Tyk decides access on path and method alone. A single endpoint such as
/connectionsmeans "my own connections" when called bare, and "somebody else's" when called with apersnbrparameter — the same URL and method, two very different levels of access.The customer cannot express "the public application may call this endpoint, but only without
persnbr", so they maintain four endpoints for one operation and redirect them internally back onto the real one — a workaround that hits TT-6699, where the query parameters get dropped in transit. Apigee and Azure API Management both support this today.How This Has Been Tested
Unit tests for the evaluator and the validator; end-to-end tests through a running Gateway for the customer scenario (bare request allowed,
?persnbr=/?agreenbr=/?account=refused, unrelated parameters still allowed), for grant precedence in both orderings, and for a stored uncompilable pattern denying rather than erroring.Because the middleware runs after authentication against the normalised session, conditions are auth-agnostic by construction. That is also exactly the kind of argument that stops being true without anyone noticing, so there is now a test running identical conditions through an auth-token session and a JWT-derived one and asserting the same allow/refuse results.
URL Rewrite, policy, key-handler, CoProcess and session suites all pass; the earlier manual PoC verification (25 end-to-end scenarios across auth token and JWT) still holds.
Types of changes
Checklist
Ticket Details
TT-16132
Generated at: 2026-09-07 12:02:58