feat(policy): Implement legacy Policy API (/v3/policies) - #1127
Open
ShJ-code wants to merge 1 commit into
Open
Conversation
ShJ-code
force-pushed
the
feat/policies-1035-clean
branch
from
July 31, 2026 03:01
44dd780 to
2da193b
Compare
Collaborator
Author
10 tasks
ShJ-code
force-pushed
the
feat/policies-1035-clean
branch
from
July 31, 2026 14:56
2da193b to
a89db4e
Compare
Add the deprecated `/v3/policies` document store as a new `policy_store` domain, closing one row of the python compatibility matrix (openstack-experimental#1035). The API stores opaque policy documents that remote services fetch and interpret; it is unrelated to this service's OPA-based authorization, hence `policy_store` rather than `policy` (`core::policy` is the OPA enforcement client, and `[api_policy]` its config section). Routes mirror python keystone exactly: GET/POST on the collection and GET/PATCH/DELETE on the member, with no PUT. Compatibility details carried over from upstream: `blob` is accepted as a string only and returned as the stored JSON value; `type` is required but, matching upstream's `maxLength`-only schema, may be empty; `id` on create is an unconstrained additional property that is accepted in any JSON shape and discarded; a PATCH `id` must equal the path; an empty patch document is a 400; extras round-trip and are merged on PATCH; and additional-property names are normalized on create only. Security: `list` re-checks every item with the per-item show policy and propagates non-Forbidden policy errors (I8). OPA input is built from an allowlist of `id`/`type` rather than by filtering, because `blob` and `extra` are arbitrary caller-supplied data with an open key space (I7). Handlers and the SQL backend use `skip_all` spans so those fields never reach a debug log. The service layer is the sole source of the policy id, assigning it before the audited operation so the event carries the final identifier; the backend rejects a missing id rather than minting a second one. A malformed stored `extra` is an error rather than an empty map, since `update` writes the decoded extras back and would otherwise destroy a row's properties on an ordinary PATCH. The new Rego reads `input.credentials.system`, the key `Credentials` actually emits; several older policies compare against a `system_scope` key that is never emitted, which is recorded as a known gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Sihao Jiang <sihao_jiang@outlook.com>
ShJ-code
force-pushed
the
feat/policies-1035-clean
branch
from
July 31, 2026 19:00
a89db4e to
a2cd694
Compare
Collaborator
Author
|
I removed the previous pagination fix and the api implementation is ready for review. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the deprecated
/v3/policiesdocument store, closing one row of thePython API compatibility matrix (#938). Fixes #1035.
Tempest's
test_create_update_delete_policyandtest_list_policiespassagainst keystone-py but fail against keystone-rs because the endpoint simply
doesn't exist. This adds it as a new
policy_storedomain.Naming: the API stores opaque policy documents that remote services fetch
and interpret; it is unrelated to this service's own OPA-based authorization.
Hence
policy_storerather thanpolicy—core::policyis already the OPAenforcement client and
[api_policy]its config section.Routes mirror python keystone exactly:
GET/POSTon the collection,GET/PATCH/DELETEon the member, and noPUT. Authorization mirrorskeystone/common/policies/policy.py:list/showare admin-or-system-reader,create/update/deleteare admin-only.Compatibility details carried over from upstream:
blobis accepted as a string only, and returned as the stored JSON value(rows written by older python releases hold an object, so the response type
is an unconstrained JSON value rather than
string).typeis required but may be empty, matching upstream'smaxLength-onlyschema.
idon create is an unconstrained additional property: accepted in any JSONshape and discarded. The service layer is the sole source of the identifier.
PATCHidmust equal the path; an empty patch document is a400.extraand are merged onPATCH(supplied keys win, omitted stored keys survive). Note this differs from e.g.
RegionUpdate.extra, which this codebase overwrites wholesale.:/-→_) on create only,matching upstream's
_normalize_dict;PATCHdoes not normalize.Second commit: shared pagination fixes
fix(api): Fix pagination limit, filters, and rowsfixes three defects in theshared v3/v4 pagination helpers that every paginated collection inherits. They
are bundled here because
/v3/policiesis the endpoint that surfaced allthree, and the list handler cannot be correct without them.
PaginationQuery::limitdefaulted toSome(20), so a request that omits?limitstill arrived with one.Config::resolve_list_limitonly consultsconfigured values when the request carries none, so every per-provider and
global
list_limitwas dead config, contrary to the precedence chain ADR0029 documents. Now
None, matching python keystone's unset[DEFAULT] list_limit.query.limit— the raw client value — while the handlerhad fetched
resolve_list_limit(...) + 1rows. A request forlimit=100against a
max_list_limitof 10 returned all 11 fetched rows, exceeding thecap, and emitted no
nextlink. Both paginators now resolve the effectivelimit themselves.
build_pagination_linkrebuilt the href from pagination controls alone,dropping every resource filter, so following the link from
GET /v3/policies?type=application/jsonsilently widened page two to theunfiltered collection. Non-pagination query parameters now carry over
verbatim.
Separately, a list endpoint that re-checks each item against the per-item read
policy (I8) cannot over-fetch
limit + 1rows just once: if any are filteredout, the surviving count can fall to
limitor below while more visible rowsremain, which the paginator reads as "no next page".
collect_authorized_pagekeeps pulling batches until
limit + 1authorized items are collected or thebackend is exhausted, bounded at
limit * AUTHORIZED_PAGE_SCAN_FACTOR(2) rawrows per request so a caller with sparse visibility cannot force a full scan.
identity/credential/listadopts it too.When the scan budget stops the refill before the page fills, the page is short
but
nextis emitted anyway, keyed on the last authorized item, so the callerkeeps paging instead of treating it as the end of the collection. If no
authorized item was found at all, no link is emitted — the only available
marker would name an object the caller may not read — and the truncation is
logged.
Test plan
All green on this branch, rebased onto
main(e2f52af0) with no conflicts.cargo check --workspacecargo clippy(changed crates)cargo fmt --checkcargo test -p openstack-keystone --lib policycargo test— api-types, core, core-types, config, policy-store-driver-sqlopa test policy/policy/opa test policy/cleancargo nextest run -p test_integrationcargo nextest run -p test_integration --profile raftpolicy_storeintegration (CRUD)test_api—api_v3::policy::*(live server)New coverage: each of the five handlers has ≥3 unit tests (valid auth,
positive/negative policy, invalid auth) per
AGENTS.md, plus 5 rego test files,11
policy_storeprovider integration tests, and 13 live-servertest_apitests across create/show/list/update/delete.
Note on unrelated
test_apifailures in this environmentA full
cargo nextest run -p test_api --profile ci-apirun on this branchreports ~30 failures outside
api_v3::policy, all401 Unauthorizedcascadingfrom failed
/v3/auth/tokenscalls. These reproduce identically onmainwithout this branch's changes and are environmental, not a regression here:
tools/teardown-api.shonly removes/tmp/nextest/keystone/raft, sostart-api.shrestarts against half-stale state (12s setup instead of a 107scold bootstrap) and blanket-401s. Worth a separate issue.
Security review checklist
Required if this diff touches authentication, scope, delegation, tokens,
credentials, EC2, trusts, application credentials, or OPA policy input.
Delete this section entirely if it doesn't apply. See
doc/src/security.md§7 for the full contextbehind each item.
(
project_id,ScopeInfo) where it should read the chain(
authentication_context(), delegation object)? (I1/I2)— No. Policies are global objects with no project binding; no rule reads
scope or any delegation fact.
onto
Credentialsfrom the chain, and does the rule anchor ondelegated_project_id+ carry the scope-drift tripwire? (I2/I3)— N/A by construction. Writes are admin-only, reads admin or
reader+system == "all". Nothing delegation-sensitive to anchor.roles still bounded by the delegation? Added a test that a restricted
delegation cannot exceed its roles via the new path? (I4)
— N/A, no new scope shape or redemption path.
ScopeInfovariant or auth method? Updatedvalidate_scope_boundaries(),calculate_effective_roles(),fully_resolved(), andCredentials::try_from— all without adding awildcard
_ =>arm? (I5, Gate J)— N/A, neither added.
sha256(access))? Does itre-assert
type/shape after fetch? (I6)— N/A. Lookup is by server-minted UUID only; a caller-supplied
idoncreate is discarded, never used as a key.
Gate I)
— No.
blobandextraare arbitrary caller-supplied data with an openkey space, so
to_policy_input()constructs the input from anallowlist (
id,type) rather than filtering known-bad keys — adenylist cannot cover
extra. Handlers and the SQL backend useskip_allspans so those fields never reach a debug log either.per-item read policy? (I8)
— Yes.
GET /v3/policiesre-enforcesidentity/policy/showper row.A
Forbiddendrops the item; every other error propagates, so an OPAoutage fails the request rather than silently returning a truncated
collection.
request-supplied scope? (I5)
— No. No scope is consulted anywhere in this domain.
positive tests proving the happy path works?
— Yes: per-handler forbidden/unauth tests, rego
*_test.regodeny cases,test_list_omits_items_denied_by_show_policy,test_list_propagates_non_forbidden_policy_errors,test_list_runs_one_show_check_per_item, and*_policy_input_contracttests asserting no secret or caller-controlled payload reaches OPA.
ValidatedSecurityContext::new_for_scope()end-to-end, not just the inner helper (
calculate_effective_roles,validate_scope_boundaries) in isolation?— Yes.
real_policy_decision::*tests drive decisions through a realopa runsubprocess, and the 13test_apitests go through the liveserver's full auth path.
Reviewer note on pagination
The pagination refactor touches ~15 list endpoints. Those edits are mechanical
(resolve the effective limit before querying; pass
&Uriinstead of&str) andremove no authorization step. The one part worth reading closely is
collect_authorized_page: its internal fetch marker advances over rawrows, authorized or not, but that value never escapes — the client-facing
nextmarker is always taken from an authorized item.