Repository navigation
fix(auth): evaluate resource tags only as aws:ResourceTag, matching DynamoDB - #421
Conversation
|
|
||
| - An `Allow` with a positive operator (`StringEquals`, `StringLike`, and similar) on | ||
| `dynamodb:ResourceTag/` stops granting: the condition no longer matches, so the statement | ||
| no longer applies. |
There was a problem hiding this comment.
These bullets hold for the bare operators, but an absent key evaluates differently under the IfExists, ForAllValues and Null forms (condition.rs:43-123), and for some of them the outcome is the opposite of what's described here. On a SQLite build of this branch, with a table tagged env=prod and D meaning dynamodb:ResourceTag/env:
Allow GetItem if StringEqualsIfExists D=devandAllow GetItem if ForAllValues:StringEquals D=devnow allow on every table, where fix(auth): enforce trust-policy Deny and match IAM condition evaluation #420 denied. The sameIfExistsform in a permissions boundary now lets GetItem through.Allow GetItem if Null D=truenow allows, andAllow * + Deny if Null D=falseno longer blocks.Deny if StringEqualsIfExists D=devnow starts blocking, rather than stopping.ForAnyValue:StringNotEqualsstays false, so a negated operator doesn't always flip.
The ones that matter most are the Allows and boundaries that were written to scope access by tag and now grant everything, since an operator reading this note would expect those to fail closed. Might be worth listing the shapes separately (bare positive, bare negated, IfExists, ForAllValues, ForAnyValue, Null) with the outcome for each, and mirroring that in the Breaking changes section of the description.
There was a problem hiding this comment.
Thanks, you're right that the note only held for the bare operators. The PR now rewrites stored policies with extenddb migrate, so a policy that existed before the upgrade keeps its decision under every operator form (ad250e3, 7046522). The upgrade manual has a table of each shape for a policy that isn't rewritten, for example one written later from an old template: bare positive, bare negated, IfExists, ForAllValues, ForAnyValue, Null true and false, and the ${...} variable. I checked each row against the new build with a table tagged env=prod.
| found.into_iter().collect() | ||
| } | ||
|
|
||
| fn collect_keys(value: &Value, out: &mut BTreeSet<String>) { |
There was a problem hiding this comment.
Condition values go through expand_policy_variables, which calls resolve_key and leaves an unresolvable variable as a literal, so a policy that uses ${dynamodb:ResourceTag/env} as a policy variable also changes meaning, but this only looks at object keys (and ignores_key_name_appearing_as_a_value pins that). On a SQLite build, Allow GetItem if StringEquals aws:PrincipalTag/env = ${dynamodb:ResourceTag/env}, which is the usual principal-tag-equals-resource-tag ABAC pattern, went from allow to deny, and the mirror forms (a StringEquals Deny, a StringNotEquals Allow) open access instead. A policy whose only reference was the variable got no warning on restart. Could consider also matching string values that contain ${dynamodb:resourcetag/ (any case), and adding the variable form to the upgrade note.
There was a problem hiding this comment.
migrate now rewrites ${dynamodb:ResourceTag/...} inside condition values as well as the condition keys, in any case (ad250e3). I checked the principal-tag-equals-resource-tag pattern on main and on the migrated build, and it gives the same decisions. One case still differs: on a table without the tag, neither form expands, so the literal left in the comparison changes from ${dynamodb:ResourceTag/<key>} to ${aws:ResourceTag/<key>}. The upgrade manual says so.
| &account_id, | ||
| &label, | ||
| "<trust policy>", | ||
| &mut findings, |
There was a problem hiding this comment.
I think trust policies are a false positive here: they're evaluated with AssumeRoleContext, whose resolve_key never resolved dynamodb:ResourceTag/ on #420 either, so their behaviour doesn't change, and on a SQLite build an AssumeRole gated on StringNotEquals dynamodb:ResourceTag/... succeeded both before and after. The warning tells the operator to rewrite them to aws:ResourceTag/, which won't change anything on that path, and the upgrade note's "Who is affected" list includes them. The opposite case is session policies, which are evaluated with RequestContext and do change (a session-policy Deny on StringEquals dynamodb:ResourceTag/env=prod stopped blocking), but aren't scanned and aren't mentioned in the note. Since sessions can last up to 12 hours, a sentence about re-issuing them might be enough.
Relatedly, the scan only runs at startup, so a policy using the key that's written after the upgrade (for example copied from an old template) has no effect and gives no signal until the next restart. A warning at the put_policy / boundary write points, like the one logged here, might be worth adding alongside the startup scan.
There was a problem hiding this comment.
Trust policies aren't touched now: the AssumeRole path never resolved the key, as you found, so the migration leaves them alone. Session policies are stored in iam_sessions on all four backends, and migrate rewrites them along with the identity policies and boundaries. The session token carries no copy of the policy, and the authorization cache is cleared on restart, so a session issued before the upgrade is evaluated with its rewritten policy.
The startup scan is gone. serve now refuses a catalog that hasn't been migrated (0cfb988). I didn't add a warning when a policy is written with the old key after the upgrade. IAM accepts that key and DynamoDB ignores it, and we now do the same. The upgrade manual's table covers what such a policy does.
| }; | ||
|
|
||
| for (account_id, _account_name) in accounts { | ||
| if let Ok(users) = store.list_users(&account_id).await { |
There was a problem hiding this comment.
The if let Ok(...) here, and for groups, roles and boundaries, drops the error without logging it, and the account and policy listing errors only log at debug, while the default level is info. So if any lookup fails, the scan is silently incomplete and the operator sees a clean start, which reads as "no affected policies". A couple of concrete paths: on SQLite, list_roles fails the whole call if one trust policy row doesn't parse, which skips every role, role policy and boundary in that account, and a transient error on list_all_accounts at startup skips everything. Might be worth counting failures and emitting one warn-level line such as "resource-tag scan incomplete: N lookups failed" when the count is non-zero.
There was a problem hiding this comment.
The scan is gone, and migrate doesn't drop errors. Every failed policy lookup is counted. If the count isn't zero, migrate --yes prints it, exits non-zero and doesn't record the catalog as migrated, so serve keeps refusing and the next run finishes the rewrite (7046522). A stored session policy that doesn't parse can't be evaluated under either form. It's logged with its account, role and session name and skipped (97ef9a9).
| sys.path.insert(0, os.path.dirname(__file__)) | ||
| from management_helpers import ManagementClient # noqa: E402 | ||
|
|
||
| BINARY = os.environ.get("EXTENDDB_SQLITE_BINARY") or os.environ.get("EXTENDDB_BINARY", "") |
There was a problem hiding this comment.
As far as I can tell this test is skipped in every CI leg: no workflow sets EXTENDDB_SQLITE_BINARY or EXTENDDB_BINARY for the pytest runs (integration.yml builds target/release/extenddb and calls devtools/run-tests, which uses its own shell variable), and the module skips when both are empty. It passes locally when pointed at a binary. Could consider falling back to target/release/extenddb as lifecycle_helpers.py does, or setting the variable in the SQLite job. It would also be worth asserting that the first server start (before the policy is stored) logs no scan line, and checking the count in the summary, so a scan that always warns doesn't pass.
There was a problem hiding this comment.
Thanks, that was right. The startup scan and its test are gone in the rebuild. The new test, tests/test_resource_tag_migration.py, takes the binary from lifecycle_helpers.EXTENDDB_BINARY, so it uses target/release/extenddb like the other lifecycle tests and needs no extra variable. It skips only when the binary rejects init --backend sqlite, which is the PostgreSQL leg.
In the SQLite job on 5bc74f3 (run-integration-sqlite, job 119791755900) both tests ran and passed: test_resource_tag_migration_wire and test_resource_tag_migration_collisions.
On your second point, the test checks that the first start of a fresh catalog logs nothing about the migration. After a user policy, a group policy and a boundary with the key are stored, migrate without --yes lists the rewrites and exits non-zero with the stored documents unchanged. After migrate --yes, no stored policy contains dynamodb:ResourceTag, and a second migrate prints No dynamodb:ResourceTag condition keys found. A migrate that always reported rewrites would fail that last check.
jcshepherd
left a comment
There was a problem hiding this comment.
Should this tag update be treated as a migration? First, it changes the authorization semantics with only warnings that an end user may or may not see. Second, it seems like fixing it is a fairly mechanical change and preserves the user's intent. Third, we probably want to avoid putting linters, etc., in the path of 'serve', especially when their lifetime is indefinite. If we can't treat it as a migration, can we at least provide tooling that users can use to correct their policies?
…ynamoDB Resolve resource tags only as aws:ResourceTag/<key>. Let dynamodb:ResourceTag/<key> fall through to absent in any case, so a policy gated on it keeps IAM absent-key semantics: a positive operator never matches, a negated operator matches, and it never fails closed. This matches the live service, which does not populate that key. Add operator-form unit tests on the real request context covering the bare, IfExists, ForAllValues, ForAnyValue and Null shapes for both the aws: and dynamodb: prefixes.
…aws:ResourceTag
Add a backend-independent rewrite over the management store. A pure
transform (rewrite_policy_document) renames every dynamodb:ResourceTag/
condition key (any case) to aws:ResourceTag/ inside Condition blocks and
rewrites every ${dynamodb:ResourceTag/...} policy variable in condition
values, under every operator and qualifier, leaving the tag key and all
other text unchanged. An operator block that already holds both the aws
and dynamodb form of one tag key is a collision: the policy is left
unchanged and reported for manual attention. The transform is idempotent.
The orchestration (migrate_resource_tag_condition_keys) drives it over
inline user, group and role policies, user and role permissions
boundaries, and stored session policies, backend-independently. Role
trust policies are not rewritten: the AssumeRole path never resolved the
key. Storage errors are counted and the pass continues; no policy body
is logged or returned. Add session enumeration and update to the
management store trait with a default empty implementation, implemented
on all four backends, and a catalog-only management-store factory for
the migrate command.
Tests: per-operator and variable-form rewrite tests, a collision test,
an idempotency test, and an equivalence test that evaluates each stored
shape with both-prefix semantics before the rewrite and aws-only
semantics after and asserts the same decision; plus a SQLite store test
covering inline, boundary and session rewrites, collision reporting and
idempotency.
After the schema migrations, migrate detects stored policies that name a dynamodb:ResourceTag condition key and, with --yes, rewrites them to aws:ResourceTag through a catalog-only management store. Without --yes it lists each account, principal, policy name and key count and writes nothing. It runs on every migrate, so an interrupted run is finished by the next. Every storage error is counted; on a non-zero count migrate prints the count, exits non-zero, and does not record the completion marker, so the rewrite is retried. A clean run records a settings marker that serve reads before starting. No policy body is logged. Add the resource_tag_migration settings key.
…h catalogs serve reads the resource_tag_migration settings row at startup and refuses to start when it is missing, naming extenddb migrate --yes. One settings-row read, no policy scan. Every fresh-catalog path seeds the marker: the SQLite, PostgreSQL and Cassandra schemas and the MongoDB catalog bootstrap record it on creation, so init, bootstrap-on-serve, in-memory and dev-mode deployments serve without a separate step. A catalog created before this change has no such row, so the server refuses it until migrate records it. Tests: the SQLite schema seeds the marker and applying the schema records the row.
Drive a SQLite deployment through init, serve, policy storage of every operator shape, migrate without --yes (listing and counts, nothing changed on disk), migrate --yes, restart, and a GetItem decision that flips from denied to allowed once the rewritten aws:ResourceTag policy resolves the table tag. Delete the marker row with the sqlite3 module and confirm serve refuses, naming extenddb migrate --yes. Assert the first start of a fresh catalog logs no migration line. The binary is found the way lifecycle_helpers does, falling back to target/release/extenddb, and the test skips on a non-SQLite binary, so the SQLite integration job runs it and the PostgreSQL job skips it.
Change every reference from dynamodb:ResourceTag to aws:ResourceTag across the manuals and design docs. Add a v0.1.14 upgrade-manual entry: what changed, that extenddb migrate --yes is required and what it prints, the collision case, a table of statement shapes giving the outcome for a policy that was not migrated (bare positive and negated, IfExists, ForAllValues, ForAnyValue, Null, policy variable), and a note that sessions issued before the migration are rewritten in the catalog and should be re-issued rather than waited out.
… wire test Update the ABAC tests to the resource-tag behaviour: the resolving cases use aws:ResourceTag, a lowercase dynamodb:ResourceTag prefix is expected absent, and a new class covers every operator form on both the aws and dynamodb prefixes against a live-measured table. Scrub ambient admin and endpoint variables in the resource-tag wire test so its own deployment generates and uses its own admin credential.
… is left unchanged A policy whose condition operator names both aws:ResourceTag/<key> and dynamodb:ResourceTag/<key> for one tag key was left unchanged, but migrate --yes still recorded the migration marker and exited 0, so serve started. With the dynamodb: key now absent, a Deny written that way stopped applying. - When both keys carry the same value they were one condition, since both prefixes resolved from the same table tags. The rewrite now drops the dynamodb: key. Two dynamodb: spellings of one key are handled the same way. - When the values differ, migrate lists the policy as NEEDS ATTENTION, exits non-zero and does not record the marker, so serve keeps refusing the catalog until the policy is fixed and migrate is run again. A run without --yes lists these policies among the pending items. - A stored session policy that does not parse is logged with its account, role and session name instead of being skipped without a trace. Tests: unit tests for the same-value and different-value cases, two spellings of one key, and text outside Condition blocks staying unchanged; a SQLite wire test that stores both kinds of policy, checks that migrate exits non-zero without the marker and serve refuses, fixes the policy, runs migrate again and checks the Deny still applies.
- PostgreSQL deployments pass --pg-user and EXTENDDB_PG_PASSWORD to
migrate, as for init.
- The equivalence statement names its one exception, an unresolved
${dynamodb:ResourceTag/<key>} variable, whose literal text changes.
- Same-value duplicates are dropped; different values block completion
until the policy is fixed with the previous version still serving.
- Sessions issued before the upgrade are evaluated with their rewritten
policy and do not need to be issued again.
15106d8 to
5bc74f3
Compare
|
@jcshepherd Agreed, it's a migration now. I've rebuilt the PR on
|
Brings in #418 (LeadingKeys on Query, batch and transaction operations) and #421 (resource tags only as aws:ResourceTag). authorize_request in request_helpers.rs keeps main's key-info snapshot (AuthorizedKeyInfo, key_info_for_authz, storage reads for batch and transaction tables) and LeadingKeys extraction (leading_keys::single_table_leading_keys, batch_transact_targets), and this branch's resource derivation (authz_targets, table_resource_arn, the DescribeEndpoints, tag-shape, operation-ARN and GetRecords branches, base_params). A TableName given as a table ARN is looked up through key_lookup_name for LeadingKeys and left out of the snapshot handed to the engine, as before. Batch and transaction tables are read under the name the request uses, so a table named by ARN is recorded as missing and the engine answers ResourceNotFound from the same snapshot. The empty-target fallback uses authz_resource::table_wildcard, now pub(crate), in place of build_resource_arn. The upgrade manual keeps both entries, the LeadingKeys entry first. After #421 dynamodb:ResourceTag is absent for every resource, so the index tag inheritance tests in test_authz_resource_arn.py exercise aws:ResourceTag only, and the admin guide no longer says index reads evaluate dynamodb:ResourceTag.
What
dynamodb:ResourceTag/<key>is now absent in every policy, as on DynamoDB, which never populates that key. Resource tags evaluate only asaws:ResourceTag/<key>, which is unchanged. Before this change ExtendDB filleddynamodb:ResourceTag/<key>from the table's tags, so a condition on it matched where DynamoDB treats the key as absent.Stored policies are migrated so that none of them changes meaning:
extenddb migraterewrites everydynamodb:ResourceTag/<key>condition key and every${dynamodb:ResourceTag/...}policy variable to theaws:ResourceTag/form, in any case, under every operator. It covers inline user, group and role policies, user and role permissions boundaries, and stored session policies. OnlyConditionblocks are changed. Role trust policies are left alone, because the AssumeRole path never resolved the key.--yes,migratelists each policy it would rewrite (account, principal, policy name and key count) and writes nothing. With--yesit rewrites and records that the catalog is migrated. Policy bodies are never printed.aws:ResourceTag/<key>anddynamodb:ResourceTag/<key>with the same value, the two were one condition before this change, and thedynamodb:key is dropped. With different values,migratelists the policy asNEEDS ATTENTION, exits non-zero and does not record the catalog as migrated.migrateexit non-zero without recording the catalog as migrated, so the next run finishes the rewrite.extenddb serverefuses to start on a catalog that has not been migrated, with an error that namesextenddb migrate --yes. A new catalog frominit, bootstrap on serve, in-memory or dev mode is recorded as migrated when it is created, on all four backends.There is no scan at server start. The manuals and design docs now use
aws:ResourceTag, anddocs/manuals/07-upgrade-manual.mdhas a v0.1.14 entry with the migrate step and a table of what each statement shape does for a policy that is not rewritten.Why
With the key resolved, a policy written for DynamoDB behaved differently on ExtendDB. An
Allowgated on a positive operator overdynamodb:ResourceTag/<key>granted where DynamoDB grants nothing, and a negated operator on it did not match where DynamoDB matches. Changing the evaluation alone would have changed the meaning of policies already stored with that key, so the change ships with a migration that rewrites them to the form DynamoDB evaluates.Testing done
tests/test_resource_tag_migration.py, run by the SQLite integration job and skipped on the PostgreSQL job:migratewithout--yesand checks the listing and that nothing changed, then runsmigrate --yesand checks the decisions after a restart.serverefuses a catalog without the migration marker.migrate --yesexits non-zero without the marker and thatserverefuses, then fixes the policy, runsmigrateagain and checks that the same-value Deny still applies.main. The second fails on the previous head of this branch, wheremigrate --yesrecorded the catalog as migrated with a collision left in place.tests/test_abac.py: the resource-tag cases useaws:ResourceTag, and new cases pindynamodb:ResourceTagas absent. Each expected result was measured against DynamoDB.IfExists,ForAllValues,ForAnyValueandNulloperators, and for the rewrite: every form, mixed case, collisions, text outsideConditionblocks, idempotency. An equivalence test evaluates each stored shape onmain's semantics before the rewrite and on the new evaluator after it, and requires the same decision. SQLite store tests run the migration end to end.mainon SQLite and on PostgreSQL:serverefused the old catalog untilmigrate --yesran. On SQLite, stored policies gave the same decisions after the migration as onmain, in 40 cases across operator forms, policy kinds and tag states. Trust policies were unchanged.cargo fmt --check,cargo clippyon 1.97.0 with-D warnings, andcargo test --workspaceon stable and 1.88.tests/test_resource_tag_migration.py,tests/test_abac.py,tests/test_auth_integration.pyandtests/test_auth_permissions.pypass on SQLite (212) and on PostgreSQL (210, with the 2 SQLite-only tests skipped).Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
ADR / RFC: n/a. The
ManagementStoretrait gainslist_session_policiesandupdate_session_policy, with default implementations, for the migration, and the catalog gains one settings row that records the migration.docs/adr/0022-trust-policy-deny-over-allow.mdlists the resolved keys.Breaking changes
extenddb migrate --yesmust run before the new binary serves an existing catalog. On PostgreSQL, pass--pg-userand setEXTENDDB_PG_PASSWORD, as forinit.dynamodb:ResourceTag/<key>and is written after the upgrade, for example from an old template, is not rewritten. Under it, a positive operator no longer matches and a negated operator now matches. The upgrade manual has the table for every operator form.migratecompletes.By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.