From a0a17d7f8b57d7773827056ea40a07df9a664c47 Mon Sep 17 00:00:00 2001 From: Cowan Macady Date: Wed, 30 Sep 2026 16:29:13 +0200 Subject: [PATCH] feat: add claims and audit implement [ENG-9616] --- .github/ISSUE_TEMPLATE/bug-report.md | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/docs.yaml | 1 + Pipfile.lock | 376 +++++++++++++-------------- README.md | 82 +++++- examples/audit_logs.py | 58 +++++ indykite_sdk/__init__.py | 6 +- indykite_sdk/_core/errors_map.py | 11 +- indykite_sdk/_core/ops.py | 26 +- indykite_sdk/audit/__init__.py | 16 ++ indykite_sdk/audit/_ops.py | 55 ++++ indykite_sdk/audit/aio.py | 105 ++++++++ indykite_sdk/audit/client.py | 140 ++++++++++ indykite_sdk/audit/models.py | 138 ++++++++++ indykite_sdk/authzen/_ops.py | 28 +- indykite_sdk/authzen/aio.py | 15 +- indykite_sdk/authzen/client.py | 37 ++- indykite_sdk/authzen/models.py | 9 + indykite_sdk/ciq/aio.py | 5 +- indykite_sdk/ciq/client.py | 27 +- indykite_sdk/config/client.py | 7 +- indykite_sdk/config/models/core.py | 10 +- tests/integration/conftest.py | 26 +- tests/integration/test_audit.py | 62 +++++ tests/unit/audit/__init__.py | 1 + tests/unit/audit/test_audit.py | 281 ++++++++++++++++++++ tests/unit/authzen/test_authzen.py | 41 +++ tests/unit/ciq/test_ciq.py | 31 ++- tests/unit/test_parity.py | 1 + 29 files changed, 1357 insertions(+), 242 deletions(-) create mode 100644 examples/audit_logs.py create mode 100644 indykite_sdk/audit/__init__.py create mode 100644 indykite_sdk/audit/_ops.py create mode 100644 indykite_sdk/audit/aio.py create mode 100644 indykite_sdk/audit/client.py create mode 100644 indykite_sdk/audit/models.py create mode 100644 tests/integration/test_audit.py create mode 100644 tests/unit/audit/__init__.py create mode 100644 tests/unit/audit/test_audit.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 3f4066c..9cd2a38 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -16,7 +16,7 @@ SDK version: `python -c "import indykite_sdk; print(indykite_sdk.__version__)"` Python version: `python --version` Platform: output of `uname -a` (UNIX), or Windows version and 32/64-bit Client: the SDK client involved (ConfigClient, CaptureClient, AuthZENClient, -CIQClient, DataSchemaClient, EntityMatchingClient - sync or async) +CIQClient, DataSchemaClient, EntityMatchingClient, AuditClient - sync or async) --> * **SDK version**: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b52fd99..14e2a30 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,7 +22,7 @@ it becomes the squash-commit message that drives the release version. ## Affected client(s) +EntityMatchingClient, AuditClient, core (auth/transport), packaging/CI, ... --> ## Description of change diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 1337a3f..1e9f9cb 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -40,6 +40,7 @@ jobs: pipenv run pdoc indykite_sdk indykite_sdk.errors + indykite_sdk.audit indykite_sdk.capture indykite_sdk.authzen indykite_sdk.ciq diff --git a/Pipfile.lock b/Pipfile.lock index d2b84ff..4f4acc2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -412,11 +412,11 @@ }, "astroid": { "hashes": [ - "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", - "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0" + "sha256:366c99c2907b6407869b1bbfd8abb10f3aea5818309e9c93c9b9c93ecf14bc4f", + "sha256:d03854b09d92c08e18d8e7d9185d393961186ed0747136d8fcb2d1c008a504ec" ], "markers": "python_full_version >= '3.10.0'", - "version": "==4.0.4" + "version": "==4.3.3" }, "certifi": { "hashes": [ @@ -431,130 +431,130 @@ "toml" ], "hashes": [ - "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", - "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc", - "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", - "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", - "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876", - "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", - "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de", - "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d", - "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", - "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", - "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec", - "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", - "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", - "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38", - "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", - "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", - "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", - "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", - "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b", - "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e", - "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", - "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", - "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", - "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", - "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a", - "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", - "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b", - "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", - "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f", - "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db", - "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", - "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", - "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", - "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae", - "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853", - "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", - "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", - "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5", - "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", - "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", - "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", - "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", - "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1", - "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", - "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", - "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c", - "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855", - "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", - "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", - "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", - "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594", - "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", - "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", - "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae", - "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90", - "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", - "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0", - "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", - "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", - "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", - "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", - "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef", - "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a", - "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", - "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", - "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6", - "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", - "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097", - "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", - "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", - "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d", - "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8", - "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", - "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", - "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220", - "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743", - "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6", - "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", - "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", - "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", - "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0", - "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040", - "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", - "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", - "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", - "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881", - "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", - "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df", - "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", - "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", - "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", - "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", - "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", - "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", - "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", - "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", - "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", - "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", - "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", - "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1", - "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", - "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43", - "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de", - "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", - "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6", - "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827", - "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", - "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", - "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", - "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", - "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773", - "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1", - "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", - "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", - "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", - "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", - "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", - "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", - "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370", - "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", - "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9" + "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", + "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", + "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", + "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", + "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", + "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", + "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", + "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", + "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", + "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", + "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", + "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", + "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", + "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", + "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", + "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", + "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", + "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", + "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", + "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", + "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", + "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", + "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", + "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", + "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", + "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", + "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", + "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", + "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", + "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", + "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", + "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", + "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", + "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", + "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", + "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", + "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", + "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", + "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", + "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", + "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", + "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", + "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", + "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", + "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", + "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", + "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", + "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", + "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", + "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", + "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", + "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", + "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", + "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", + "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", + "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", + "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", + "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", + "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", + "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", + "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", + "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", + "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", + "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", + "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", + "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", + "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", + "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", + "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", + "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", + "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", + "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", + "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", + "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", + "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", + "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", + "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", + "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", + "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", + "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", + "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", + "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", + "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", + "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", + "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", + "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", + "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", + "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", + "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", + "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", + "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", + "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", + "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", + "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", + "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", + "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", + "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", + "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", + "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", + "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", + "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", + "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", + "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", + "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", + "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", + "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", + "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", + "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", + "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", + "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", + "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", + "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", + "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", + "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", + "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", + "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", + "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", + "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", + "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", + "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", + "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec" ], "markers": "python_version >= '3.10'", - "version": "==7.16.1" + "version": "==7.16.2" }, "dill": { "hashes": [ @@ -606,46 +606,46 @@ }, "isort": { "hashes": [ - "sha256:182918b730772292d33564a6ac5b201ca2bb79a8ad2ac77e7681ecc0f19a8f84", - "sha256:1878b5165b0db434c0c62373a81a111e1afffb373f20e57bd2020ebdbaa36808", - "sha256:1b8d6c836fb83232f5f4c1c037d332caf743bb24dca63167bad9174ae13e150e", - "sha256:2057236a764f31c78dac78f7343057621fcc2fd40461ce61061f34fd09066f46", - "sha256:23d3b6657763f9be1b15bb9664b016abfce34849d6215a46a42af7945d4acd68", - "sha256:2f41e40246742970db0227a2afb2d7da872bddd888826cf182c0916993fadb43", - "sha256:2fb33e0c0f9f87821acf6d82c83f0a0c7e54680fdf3fe4131409d2b95901f00a", - "sha256:3727eb33a9759649346481cf2a9287d656a170c31ed7c105856f9c6f5b539756", - "sha256:466b0c3f156a21c10edefba697e641666bc26ffb0122bf08b42caa3d464c20aa", - "sha256:5022b332ac91ccb39dc28bb206d5ae96ae7f8d45e710b072cb039b2fcda6602a", - "sha256:5832683294dd61c59d00cd043a68d42f6ecd7dc7d04b73ac777f7f90a534d6ae", - "sha256:5aac7263b7a7f9f647f94fb6df2761ff5b60a7168eb492ff39dd30443207fa19", - "sha256:5e72a7063570f1d740f0284c7ae5739dc34c6a2d9f1049b13027a5bdadb56682", - "sha256:67680927f739d4b48d67d8b7430faa92c95b02fb6075ca0351c6446214f6c7bb", - "sha256:7281cdf538f682b8d75fa44bcdad1b299036bbc440855f7d61412b3b85d5727d", - "sha256:771d5b7385292a0b2106229b792b8750954bbaf231e0475b1f53f1dd43e00936", - "sha256:77f4b984ab3badbbf2363c849b92465e0f69e8fc54d1a932c87532a559269397", - "sha256:7a75d4c21d8b93345a2743b96cc75c6f085aa89ddbaadd6edd5e9765be12ab77", - "sha256:7ea5f505b152fedd2b990b39d8b76108a48b355da874025aad4982e8ceeb0f3d", - "sha256:825c05d2d63a1b9c608c352503c10b6411a3c6e12bcacc97b306774ee379786f", - "sha256:873cf1b6371d41e2a74d57d7c0176d311822f0415441abf8251ad074c9fe4a66", - "sha256:89ebbcdbdd9d66cc14909bbac36acb9db29f37325606113c9f270242f8a1f896", - "sha256:8f490acc182253d07071cc8255b57a281855e2e027b929a89eaa7c797f7b213e", - "sha256:930879e4cfab3264f1d7346abeec10726b5382dc4be9f4251c25ec7fa057926b", - "sha256:98d48ad47f705ac7f046cfaab0a11320ed0b903243ccb850347229414a364d28", - "sha256:99b7bc28b1f05f7e3267629043a99c6c479a750df3689327a10324e396827f94", - "sha256:9dd4664ad009552bc4c9f464bd31190d0f04132412ee4d9392145fdf58d92127", - "sha256:ba23db109e3e93ef1999f7209a651214994cd807801addd16ac485982eb4edd7", - "sha256:c08b2989a16a46e97af652266ee8af617eb5b1bfa3195cc921cc0dc66b485d10", - "sha256:c2525606f62742fc4ed9f8ca89043b9522ac3e6f9c9892e6cb16f4870d937f38", - "sha256:c3ce022ccedf63aa5fc77bd0e926b8561a1476c9709d7cedf63abd7967772aac", - "sha256:cc9814ce2ee42c17007d822455e4db55e32e589808ecfc2665d51c848d0bb30a", - "sha256:cdf765657edb2bcccbb1b20d26e710acbcb27379c0a407c6cb376e5619059a7b", - "sha256:e3a2697ebcb54b51af4833de44447dbf31ddf081c5f163772092d21c0267483b", - "sha256:f6877ed17054eae153d686270678b11c1f6cb79433a1c07453140cccbaf7cc1d", - "sha256:fb7d55156a1f766a2b097165524f07be61ececa41a71ca33d24a00777f79a829", - "sha256:fd326823ddbe338357ba1823b7f96481d4421d54c83ebd43c92f1b51314a24ae" + "sha256:11da67a30f5a88383c71db075488ca3d081f427f53368f90bb1d74e958a9b040", + "sha256:16436aefeebe3aa2d5d7ae1ca895b2278f770fc4a41d95c22569a30f7413ec45", + "sha256:1c134ef9d94943eae14bf31c634db1904dd875e6e7280a60baee10ca06132db6", + "sha256:288a320e6d52ba2d3447345390c8a8400591e4033ffbe4ce6bc3e50e5b4818e1", + "sha256:29669ea6c410528ffe3b632a41835757f08282257e4ddac892a5e6d01bd35201", + "sha256:2a960e4252ac5b00f78adc0f731529e122657ee642e650896b36e1ff83028023", + "sha256:3cd67d39c3501d7227e8b229476da1d8679c03e0af97bd295876cf7070e5b709", + "sha256:3fe693c1e56781de387a6c206306e9e5e560cfeb4acdfd85f0c46122afd48792", + "sha256:4315e23e701bb1fcdfd364da59da61d78c3332c554318b7eb635ea3924d24c5e", + "sha256:5c929e8ec9d9fb83f034d5f50895503f40c624605f552b97ad090a37e62407ca", + "sha256:5f448510ef0a92fa626a975759d76bdbe3b721c3d615da6d1010cc451de5610d", + "sha256:67b12d9504e5bc6359bb3bb4493f36cf1093d15477c61c349f52f7d04209fb5d", + "sha256:6c29deeb39698a8717823b7f75b2ac58c5e8ab8dcf6cf31205a72a6617fb454e", + "sha256:6eb3e714d64de6eba78ee29051f7fc80613c74e90c6f54f84082f59c429c0a0b", + "sha256:71870ac3b1afdf3c259b8404c05076d3ab874122fec6f78339f1c92d2c29b012", + "sha256:810561edf6f1f5f3600f02aa709603a4360d5290c5fff2ae4b370090dd1a5445", + "sha256:85e859fd72e50c27306d05185f9472ed97fae9e1cce91c0e891260d16f2ecece", + "sha256:8dde4e2d9cfb35390437353f0861ec41378f91ff958d8cd3051fb95cae59315a", + "sha256:91b60ce3d96fcb0730d61fc5ab84ee5b56d676fbb92550f7ea333f58778f2f20", + "sha256:a05dc63cb6ae2a8e62ec4184153f424b1650593e00a24e6138184c46193891e9", + "sha256:a36f30b6b85d9726f79c7623d35f3e966d5d7d9d0a005af91ba19988fccd038b", + "sha256:aa810daf72ff5d8ade462b2190dad9c0e16d6d428a3f9aea210f14cca2487d58", + "sha256:af8be0b5cac101202c8255360e5de832ebbb84b2e863dc0f65dbb1a3d63dd40a", + "sha256:b34a165cd4e25726930ed2eed8cf2fe46fb1a5ebacd9b28eaf566b343a6457ca", + "sha256:b3e81cae981a52f94d5b31a474e1cbb033ea9cc850bc4c922117c0534a1864dd", + "sha256:bd8c4fb9829a5e7117d9f71f540ff1e8caafb471e574012057ce6dc35fda2d7b", + "sha256:bf3ef0a91974f29f406e25eef0e04781fd5c2254b8ab55e7655b20d8cd7c5514", + "sha256:cd1e0e5e61497e95a4e5be269088e6a1013f530aeccf6ebd6134f403285ecd63", + "sha256:d03c68e9d0a83b51ed381d04b0919f2d918fb66c1ca1766761157ff44149366f", + "sha256:d2298980ce44350f11d9d24c8150eaef1883431ec203dddbb4e9b5c3ceb54c70", + "sha256:d4da51a99dfd00e5c51e507ed91ebad6aafd44dc65135c17e2ef37355cd9fa98", + "sha256:e2636222848a48cadbd712280058b5da19fa147c501132e04a486a5bddcc9e28", + "sha256:e4a54aed1bb731d7cf80ef5dfbae5b960f777cea70523b751ee6049bcb604371", + "sha256:e5f11c7ccd5f079ac0431fe52c7b38ea5d9f4e31a1889746de81dac0e7b0a766", + "sha256:f65ff614632ddc3306c40f619717b3b3ca69938ffee21d97110056d52472c79a", + "sha256:f7a9efeb3689c7327a0d637eb4e12691e8d5ab1297caee997b144dc595ccb93f", + "sha256:f7c2fa33e1c9fbcf9fd639997e4550515c0b712b52ed70a059124a5247825480" ], "markers": "python_full_version >= '3.10.0'", - "version": "==9.0.1" + "version": "==9.0.2" }, "jinja2": { "hashes": [ @@ -793,11 +793,11 @@ }, "platformdirs": { "hashes": [ - "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547", - "sha256:b0befe8a90759e4a9a8b9820d434ae226a6549063210b596da0038a7a05aede4" + "sha256:29dbf06d96c500bc6bdbce75fb0a14d63279c93b1842f97e72a135b33e856983", + "sha256:eab5f70271a490ef74618bb314fbb86e3c7e82fa3b9c922c2ea0e0a1a155d329" ], "markers": "python_version >= '3.10'", - "version": "==4.11.11" + "version": "==4.12.2" }, "pluggy": { "hashes": [ @@ -817,12 +817,12 @@ }, "pylint": { "hashes": [ - "sha256:1c1b2128bde5ff5e966801413080b6384d42a5782718d528c906dbb6beab94ed", - "sha256:3341c08c0aabaa4adc71516de0969f3ba5c692b56c75af4dcb4d242823fbe363" + "sha256:47538540de0a563ff0b6cb781330944b0c9c1a130986ad1c183116ed37ec4538", + "sha256:84901850af1c67240afbe7b0ef696b7ab391ed4838e4ffc48511661026ebc565" ], "index": "pypi", "markers": "python_full_version >= '3.10.0'", - "version": "==4.0.8" + "version": "==4.1.1" }, "pytest": { "hashes": [ @@ -862,28 +862,28 @@ }, "ruff": { "hashes": [ - "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", - "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", - "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", - "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", - "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", - "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", - "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", - "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", - "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", - "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", - "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", - "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", - "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", - "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", - "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", - "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", - "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", - "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773" + "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", + "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", + "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", + "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", + "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", + "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", + "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", + "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", + "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", + "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", + "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", + "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", + "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", + "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", + "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", + "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", + "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", + "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.16.8" + "version": "==0.16.9" }, "tomlkit": { "hashes": [ diff --git a/README.md b/README.md index 58af0cd..6a6f0d6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Python clients for the [IndyKite](https://www.indykite.com) platform REST APIs: the Identity Knowledge Graph (IKG), KBAC authorization (AuthZEN), ContX IQ -knowledge queries, data capture, entity matching, and platform configuration. +knowledge queries, data capture, entity matching, the tamper-proof audit +trail, and platform configuration. - SDK API reference: - OpenAPI reference: @@ -30,7 +31,7 @@ The SDK uses the two standard IndyKite credential kinds, obtained from the | Credential | Used by | What it is | Environment variables | | --- | --- | --- | --- | -| **Application Agent** | all data-plane clients (capture, authzen, ciq, data schema, entity matching) | the **raw credential token itself** (opaque string, sent as `X-IK-ClientKey`) | `INDYKITE_APPLICATION_CREDENTIALS` (the token) or `INDYKITE_APPLICATION_CREDENTIALS_FILE` (file with the token) | +| **Application Agent** | all data-plane clients (capture, authzen, ciq, data schema, entity matching, audit) | the **raw credential token itself** (opaque string, sent as `X-IK-ClientKey`) | `INDYKITE_APPLICATION_CREDENTIALS` (the token) or `INDYKITE_APPLICATION_CREDENTIALS_FILE` (path) | | **Service Account** | `ConfigClient` | a **JSON artifact** (`serviceAccountId`, pre-issued `token`, private key), sent as `Authorization: Bearer` | `INDYKITE_SERVICE_ACCOUNT_CREDENTIALS` (inline JSON) or `INDYKITE_SERVICE_ACCOUNT_CREDENTIALS_FILE` (path) | ```sh @@ -52,6 +53,23 @@ expires the SDK self-signs a fresh JWT from the credential's private key (`privateKeyJWK` or PKCS#8). The app-agent token is never a JWT the SDK mints — it is sent exactly as issued. +Each data-plane API is guarded by one **API permission** on the application +agent, granted when the agent is created (Hub or `ConfigClient`): + +| Permission | Client | Endpoints | +| --- | --- | --- | +| `Authorization` | `AuthZENClient` | decisions and searches (`/access/v1/evaluation*`, `/search/*`) | +| `ReadAuthZConfigs` | `AuthZENClient.policies` | the project's active policies (`/access/v1/policies`) | +| `Capture` | `CaptureClient` | `/capture/v1/*` | +| `ContXIQ` | `CIQClient` | `/contx-iq/v1/*` | +| `ReadDataSchema` | `DataSchemaClient` | `/data-schema/v1` | +| `EntityMatching` | `EntityMatchingClient` | `/entity-matching/v1/*` | +| `Audit` | `AuditClient` | the tamper-proof audit trail (`/audit/v1/*`) | + +A call to an endpoint the agent is not permitted for raises +`AuthenticationError` (401, `insufficient API access level`). A permission +granted later takes a moment to reach the data plane. + ### Regions and environments Production defaults to `https://eu.api.indykite.com`; pass `region="us"` for @@ -78,6 +96,32 @@ with AuthZENClient() as client: print(policy.tags, policy.policy["actions"]) ``` +#### Token claims in policy conditions + +Decisions and knowledge queries accept two optional request tokens. They travel +as headers, never in the body, and the policy condition reads their claims: + +| Argument | Header | Claims in the policy | +| --- | --- | --- | +| `user_token` | `Authorization: Bearer` | `$token`, e.g. `$token.sub` (needs a Token Introspect configuration) | +| `delegated_token` | `X-IK-Token` | `$ik_token`, e.g. `$ik_token.act.sub` — the RFC 8693 delegation chain of a token minted by the IndyKite Token Service | + +```python +with AuthZENClient() as client: + result = client.evaluation( + ("Person", "ada"), + "CAN_DRIVE", + ("Car", "kitt"), + user_token=end_user_access_token, # $token.sub == "ada" + delegated_token=ik_delegated_token, # $ik_token.act.sub names the acting agent + ) +``` + +`token` and `ik_token` are reserved names in `input_params`: a policy never +asks for them, a value sent under them is replaced by the real claims, and a +token that was not sent binds an empty claim set, so a policy reading it denies +rather than fails. + ### Capture graph data ```python @@ -119,8 +163,35 @@ with CIQClient() as client: user_token = "" # a token your Token Introspect config can validate me = client.whoami(user_token) print(me.type, me.id) # e.g. Person ada + + # Run in the user's context; the CIQ policy reads $token.sub and $ik_token.act.sub + client.execute("gid:my-knowledge-query-id", user_token=user_token, delegated_token="") ``` +### Read the tamper-proof audit trail + +Every audit event of a project is appended to a signed **chain**: events are +collected into signed batches (`logs`), a signed **manifest** links each batch +to the previous one (`prev_hash` / `head_hash`), and signed **checkpoints** +periodically fix the chain head. The agent needs the `Audit` permission and +can only read its own project. + +```python +from indykite_sdk import AuditClient + +with AuditClient() as client: + keys = client.jwks(project_id) # public signing keys; keep them with an export + for batch in client.iter_logs(project_id): # sequence order, all pages + for event in batch.data: # untyped dicts, authored by whoever triggered them + print(batch.sequence, event.get("type")) + manifests = list(client.iter_manifests(project_id)) # linkage without payloads + newest = client.list_checkpoints(project_id, page_size=1).items # newest first +``` + +Listings return a `Page` (`items`, `has_more`, `next_cursor`); `iter_*` follows +the cursor for you. The signing key itself is configured with +`ConfigClient.create_audit_signing` (see below). + ### Manage platform configuration ```python @@ -131,7 +202,7 @@ with ConfigClient() as config: project = config.create_project("my-project", organization.id, region="europe-west1") app = config.create_application("my-app", project.id) agent = config.create_application_agent( - "my-agent", app.id, ["Authorization", "Capture", "ContXIQ", "ReadAuthZConfigs"] + "my-agent", app.id, ["Authorization", "Capture", "ContXIQ", "ReadAuthZConfigs", "Audit"] ) credential = config.create_application_agent_credential(agent.id) agent_credentials = credential.as_credentials() # shown once - store it securely @@ -145,8 +216,9 @@ app = config.read_application(app_id) config.update_application(app_id, etag=app.etag, display_name="Renamed") ``` -Audit signing decides which key signs a project's audit records. The default -is a platform-managed key; customer-managed providers bring their own key: +Audit signing decides which key signs a project's audit trail (the batches, +manifests and checkpoints `AuditClient` reads). The default is a +platform-managed key; customer-managed providers bring their own key: ```python signing = config.create_audit_signing("audit-signing", project.id) # PLATFORM_MANAGED diff --git a/examples/audit_logs.py b/examples/audit_logs.py new file mode 100644 index 0000000..2deaca3 --- /dev/null +++ b/examples/audit_logs.py @@ -0,0 +1,58 @@ +"""Export a project's tamper-proof audit trail via the Audit Log API. + +Requires INDYKITE_APPLICATION_CREDENTIALS[_FILE] for an application agent that +holds the ``Audit`` API permission, and INDYKITE_TEST_PROJECT_ID set to the +project that agent belongs to. Writes logs.json, manifests.json, +checkpoints.json and jwks.json to the current directory, which together form +a self-describing export: the manifests link the batches through +prev_hash / head_hash, the checkpoints fix the chain head at known times, and +the JWKS names the keys every signature was made with. +""" + +import json +import os + +from indykite_sdk import AuditClient + + +def main() -> None: + """Run the example.""" + project_id = os.environ["INDYKITE_TEST_PROJECT_ID"] + + with AuditClient() as client: + # The public signing keys - fetch them first and keep them with the export. + keys = client.jwks(project_id) + print(f"Signing keys: {[key.kid for key in keys.keys]}") + + # Manifests are small: the chain linkage without the event payloads. + manifests = list(client.iter_manifests(project_id)) + print(f"Chain length: {len(manifests)} batches") + for manifest in manifests[-3:]: + prev_hash, head_hash = (manifest.prev_hash or "")[:12], (manifest.head_hash or "")[:12] + print(f" #{manifest.sequence} {prev_hash}.. -> {head_hash}.. kid={manifest.kid}") + + # Logs page in lockstep with manifests; each batch carries its audit events in `data`. + logs = list(client.iter_logs(project_id, page_size=20)) + events = [event for batch in logs for event in batch.data] + print(f"Audit events: {len(events)}") + + # Checkpoints come newest first; a young project may have none yet. + checkpoints = list(client.iter_checkpoints(project_id)) + if checkpoints: + newest = checkpoints[0] + print(f"Newest checkpoint: sequence {newest.sequence} at {newest.created_at}") + + for name, items in ( + ("jwks.json", keys), + ("manifests.json", manifests), + ("logs.json", logs), + ("checkpoints.json", checkpoints), + ): + with open(name, "w", encoding="utf-8") as handle: + payload = items.model_dump() if hasattr(items, "model_dump") else [item.model_dump() for item in items] + json.dump(payload, handle, indent=2) + print(f"Wrote {name}") + + +if __name__ == "__main__": + main() diff --git a/indykite_sdk/__init__.py b/indykite_sdk/__init__.py index da66c9b..b1f4172 100644 --- a/indykite_sdk/__init__.py +++ b/indykite_sdk/__init__.py @@ -13,11 +13,13 @@ print(result.decision) Each platform API has a sync and an async client. Config API clients use -service-account credentials; all others use application-agent credentials. +service-account credentials; all others (AuthZEN, Capture, ContX IQ, Data +Schema, Entity Matching, Audit Log) use application-agent credentials. """ from indykite_sdk._core.credentials import Credentials from indykite_sdk._core.retry import RetryConfig +from indykite_sdk.audit import AsyncAuditClient, AuditClient from indykite_sdk.authzen import AsyncAuthZENClient, AuthZENClient from indykite_sdk.capture import AsyncCaptureClient, CaptureClient from indykite_sdk.ciq import AsyncCIQClient, CIQClient @@ -46,12 +48,14 @@ __all__ = [ "APIStatusError", "__version__", + "AsyncAuditClient", "AsyncAuthZENClient", "AsyncCIQClient", "AsyncCaptureClient", "AsyncConfigClient", "AsyncDataSchemaClient", "AsyncEntityMatchingClient", + "AuditClient", "AuthZENClient", "AuthenticationError", "BadRequestError", diff --git a/indykite_sdk/_core/errors_map.py b/indykite_sdk/_core/errors_map.py index 4353c17..0ebfd88 100644 --- a/indykite_sdk/_core/errors_map.py +++ b/indykite_sdk/_core/errors_map.py @@ -33,8 +33,10 @@ _HINTS: dict[int, dict[str, str]] = { 401: { "app_agent": ( - "The X-IK-ClientKey token was rejected. Ensure INDYKITE_APPLICATION_CREDENTIALS holds an " - "application-agent credential JSON (not a service-account one) and that it has not expired." + "The X-IK-ClientKey token was rejected. Ensure INDYKITE_APPLICATION_CREDENTIALS holds the " + "application-agent credential token (not a service-account credential) and that it has not expired. " + "An 'insufficient API access level' message means the agent lacks the API permission this endpoint " + "needs (e.g. Audit for /audit/v1, ReadAuthZConfigs for /access/v1/policies)." ), "service_account": ( "The bearer token was rejected. Ensure INDYKITE_SERVICE_ACCOUNT_CREDENTIALS holds a service-account " @@ -43,8 +45,9 @@ }, 403: { "app_agent": ( - "The application agent lacks the required API permission. Check its apiPermissions " - "(e.g. Capture, Authorization, ContXIQ, EntityMatching) in the IndyKite Hub." + "The application agent is not allowed to access this resource. A project_id you passed must be the " + "project the agent belongs to; the agent's API permissions (Audit, Authorization, Capture, ContXIQ, " + "EntityMatching, ReadAuthZConfigs, ReadDataSchema) are managed in the IndyKite Hub." ), "service_account": "The service account is not allowed to manage this resource.", }, diff --git a/indykite_sdk/_core/ops.py b/indykite_sdk/_core/ops.py index 8489b37..f41e554 100644 --- a/indykite_sdk/_core/ops.py +++ b/indykite_sdk/_core/ops.py @@ -22,12 +22,22 @@ class RequestSpec: headers: dict[str, str] = field(default_factory=dict) -def user_token_headers(user_token: str | None) -> dict[str, str]: - """Headers for an optional end-user access token on AuthZEN/ContX IQ calls. - - The end-user token rides in ``Authorization: Bearer`` *alongside* the - application-agent ``X-IK-ClientKey`` header. +def user_token_headers(user_token: str | None, delegated_token: str | None = None) -> dict[str, str]: + """Headers for the optional request tokens on AuthZEN/ContX IQ calls. + + Both ride *alongside* the application-agent ``X-IK-ClientKey`` header, and + the platform publishes their claim sets to policy conditions under the + reserved parameter names ``token`` and ``ik_token``: + + - the end-user access token in ``Authorization: Bearer``; its claims are + readable by policies as ``$token`` (e.g. ``$token.sub``); + - the IndyKite delegated token in ``X-IK-Token``; its claims, including + the RFC 8693 ``act`` delegation chain, are readable as ``$ik_token`` + (e.g. ``$ik_token.act.sub``). """ - if not user_token: - return {} - return {"Authorization": f"Bearer {user_token}"} + headers: dict[str, str] = {} + if user_token: + headers["Authorization"] = f"Bearer {user_token}" + if delegated_token: + headers["X-IK-Token"] = delegated_token + return headers diff --git a/indykite_sdk/audit/__init__.py b/indykite_sdk/audit/__init__.py new file mode 100644 index 0000000..0d2a687 --- /dev/null +++ b/indykite_sdk/audit/__init__.py @@ -0,0 +1,16 @@ +"""Audit Log API - read and export a project's tamper-proof audit trail.""" + +from indykite_sdk.audit.aio import AsyncAuditClient +from indykite_sdk.audit.client import AuditClient +from indykite_sdk.audit.models import Checkpoint, Key, KeySet, LogEntry, Manifest, Page + +__all__ = [ + "AsyncAuditClient", + "AuditClient", + "Checkpoint", + "Key", + "KeySet", + "LogEntry", + "Manifest", + "Page", +] diff --git a/indykite_sdk/audit/_ops.py b/indykite_sdk/audit/_ops.py new file mode 100644 index 0000000..fa7d002 --- /dev/null +++ b/indykite_sdk/audit/_ops.py @@ -0,0 +1,55 @@ +"""Sans-IO request building shared by the sync and async Audit Log clients.""" + +from __future__ import annotations + +from typing import Any + +from indykite_sdk._core.ops import RequestSpec +from indykite_sdk.errors import IndyKiteError, RequestValidationError + +LOGS_PATH = "/v1/logs" +MANIFESTS_PATH = "/v1/manifests" +CHECKPOINTS_PATH = "/v1/checkpoints" +JWKS_PATH = "/.well-known/jwks.json" + + +def _project_id(project_id: str) -> str: + project_id = (project_id or "").strip() + if not project_id: + raise RequestValidationError( + "project_id is required: the project GID the application agent belongs to (``gid:...``)." + ) + return project_id + + +def list_spec(path: str, project_id: str, cursor: str | None, page_size: int | None) -> RequestSpec: + """Build one page request of a listing endpoint (``project_id``, ``cursor``, ``pagesize``). + + ``page_size`` must be positive; the platform caps values above 50 to 50. + """ + params: dict[str, Any] = {"project_id": _project_id(project_id)} + if cursor: + params["cursor"] = cursor + if page_size is not None: + if page_size < 1: + raise RequestValidationError(f"page_size must be a positive integer, got {page_size}.") + params["pagesize"] = page_size + return RequestSpec("GET", path, params=params) + + +def jwks_spec(project_id: str) -> RequestSpec: + """Build the JWKS request; ``project_id`` is required but does not select a key today.""" + return RequestSpec("GET", JWKS_PATH, params={"project_id": _project_id(project_id)}) + + +def next_cursor(cursor: str | None, page_next_cursor: str | None, has_more: bool) -> str | None: + """The cursor of the following page, or ``None`` when this was the last one. + + Guards against a server handing out the cursor it was just given, which + would otherwise page forever. + """ + if not has_more: + return None + if not page_next_cursor or page_next_cursor == cursor: + raise IndyKiteError("The Audit Log API returned a page that does not advance the cursor; stopping.") + return page_next_cursor diff --git a/indykite_sdk/audit/aio.py b/indykite_sdk/audit/aio.py new file mode 100644 index 0000000..9150dad --- /dev/null +++ b/indykite_sdk/audit/aio.py @@ -0,0 +1,105 @@ +"""Asynchronous Audit Log client.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable + +import httpx + +from indykite_sdk._core.http import BaseAsyncClient +from indykite_sdk.audit import _ops +from indykite_sdk.audit.models import Checkpoint, KeySet, LogEntry, Manifest, Page + +__all__ = ["AsyncAuditClient"] + +type Timeout = httpx.Timeout | float | None + + +class AsyncAuditClient(BaseAsyncClient): + """Async variant of :class:`indykite_sdk.AuditClient` - same methods, ``await``-able. + + Example:: + + from indykite_sdk import AsyncAuditClient + + async with AsyncAuditClient() as client: + async for manifest in client.iter_manifests("gid:project"): + print(manifest.sequence, manifest.head_hash) + """ + + _api_prefix = "/audit" + _auth_kind = "app_agent" + + async def _list[ItemT]( + self, + path: str, + item_cls: type[ItemT], + project_id: str, + cursor: str | None, + page_size: int | None, + timeout: Timeout, + ) -> Page[ItemT]: + spec = _ops.list_spec(path, project_id, cursor, page_size) + response = await self._send(spec, timeout=timeout) + return Page[item_cls].model_validate(response.json()) # type: ignore[valid-type] + + @staticmethod + async def _iter_pages[ItemT]( + list_page: Callable[[str | None], Awaitable[Page[ItemT]]], + ) -> AsyncIterator[ItemT]: + """Yield the items of every page, following ``next_cursor`` while ``has_more``.""" + cursor: str | None = None + while True: + page = await list_page(cursor) + for item in page.items: + yield item + cursor = _ops.next_cursor(cursor, page.next_cursor, page.has_more) + if cursor is None: + return + + async def list_logs( + self, project_id: str, *, cursor: str | None = None, page_size: int | None = None, timeout: Timeout = None + ) -> Page[LogEntry]: + """One page of the project's signed chain batches, the audit events (``GET /v1/logs``).""" + return await self._list(_ops.LOGS_PATH, LogEntry, project_id, cursor, page_size, timeout) + + def iter_logs( + self, project_id: str, *, page_size: int | None = None, timeout: Timeout = None + ) -> AsyncIterator[LogEntry]: + """Every chain batch of the project, in sequence order, across all pages.""" + return self._iter_pages( + lambda cursor: self.list_logs(project_id, cursor=cursor, page_size=page_size, timeout=timeout) + ) + + async def list_manifests( + self, project_id: str, *, cursor: str | None = None, page_size: int | None = None, timeout: Timeout = None + ) -> Page[Manifest]: + """One page of the project's signed chain manifests (``GET /v1/manifests``).""" + return await self._list(_ops.MANIFESTS_PATH, Manifest, project_id, cursor, page_size, timeout) + + def iter_manifests( + self, project_id: str, *, page_size: int | None = None, timeout: Timeout = None + ) -> AsyncIterator[Manifest]: + """Every chain manifest of the project, in sequence order, across all pages.""" + return self._iter_pages( + lambda cursor: self.list_manifests(project_id, cursor=cursor, page_size=page_size, timeout=timeout) + ) + + async def list_checkpoints( + self, project_id: str, *, cursor: str | None = None, page_size: int | None = None, timeout: Timeout = None + ) -> Page[Checkpoint]: + """One page of the project's signed checkpoints, newest first (``GET /v1/checkpoints``).""" + return await self._list(_ops.CHECKPOINTS_PATH, Checkpoint, project_id, cursor, page_size, timeout) + + def iter_checkpoints( + self, project_id: str, *, page_size: int | None = None, timeout: Timeout = None + ) -> AsyncIterator[Checkpoint]: + """Every checkpoint of the project, newest first, across all pages.""" + return self._iter_pages( + lambda cursor: self.list_checkpoints(project_id, cursor=cursor, page_size=page_size, timeout=timeout) + ) + + async def jwks(self, project_id: str, *, timeout: Timeout = None) -> KeySet: + """The public keys behind every signature (``GET /.well-known/jwks.json``).""" + response = await self._send(_ops.jwks_spec(project_id), timeout=timeout) + return KeySet.model_validate(response.json()) diff --git a/indykite_sdk/audit/client.py b/indykite_sdk/audit/client.py new file mode 100644 index 0000000..6fae1a2 --- /dev/null +++ b/indykite_sdk/audit/client.py @@ -0,0 +1,140 @@ +"""Synchronous Audit Log client.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator + +import httpx + +from indykite_sdk._core.http import BaseSyncClient +from indykite_sdk.audit import _ops +from indykite_sdk.audit.models import Checkpoint, KeySet, LogEntry, Manifest, Page + +__all__ = ["AuditClient"] + +type Timeout = httpx.Timeout | float | None + + +class AuditClient(BaseSyncClient): + """Read a project's tamper-proof audit trail via the Audit Log API (``/audit``). + + Authenticates with the raw **application-agent credential token** + (``INDYKITE_APPLICATION_CREDENTIALS[_FILE]``) sent as ``X-IK-ClientKey``. + The agent needs the ``Audit`` API permission, and ``project_id`` must be + the project the agent belongs to: another project answers 403, a missing + permission 401. + + Every listing returns one :class:`~indykite_sdk.audit.Page`; the ``iter_*`` + methods follow ``next_cursor`` through every page. Logs and manifests page + in sequence order from the start of the chain, checkpoints newest first. + ``page_size`` is 1 to 50; the platform caps larger values to 50. + + Example:: + + from indykite_sdk import AuditClient + + with AuditClient() as client: + for batch in client.iter_logs("gid:project"): + for event in batch.data: + print(batch.sequence, event) + keys = client.jwks("gid:project") # the public keys behind every signature + """ + + _api_prefix = "/audit" + _auth_kind = "app_agent" + + def _list[ItemT]( + self, + path: str, + item_cls: type[ItemT], + project_id: str, + cursor: str | None, + page_size: int | None, + timeout: Timeout, + ) -> Page[ItemT]: + spec = _ops.list_spec(path, project_id, cursor, page_size) + return Page[item_cls].model_validate(self._send(spec, timeout=timeout).json()) # type: ignore[valid-type] + + @staticmethod + def _iter_pages[ItemT]( + list_page: Callable[[str | None], Page[ItemT]], + ) -> Iterator[ItemT]: + """Yield the items of every page, following ``next_cursor`` while ``has_more``.""" + cursor: str | None = None + while True: + page = list_page(cursor) + yield from page.items + cursor = _ops.next_cursor(cursor, page.next_cursor, page.has_more) + if cursor is None: + return + + # -- logs ------------------------------------------------------------------ + + def list_logs( + self, project_id: str, *, cursor: str | None = None, page_size: int | None = None, timeout: Timeout = None + ) -> Page[LogEntry]: + """One page of the project's signed chain batches, the audit events (``GET /v1/logs``). + + Args: + project_id: The project GID the application agent belongs to. + cursor: ``next_cursor`` of the previous page; omit for the first page. + page_size: Items per page, 1 to 50 (the platform default and maximum). + """ + return self._list(_ops.LOGS_PATH, LogEntry, project_id, cursor, page_size, timeout) + + def iter_logs( + self, project_id: str, *, page_size: int | None = None, timeout: Timeout = None + ) -> Iterator[LogEntry]: + """Every chain batch of the project, in sequence order, across all pages.""" + return self._iter_pages( + lambda cursor: self.list_logs(project_id, cursor=cursor, page_size=page_size, timeout=timeout) + ) + + # -- manifests --------------------------------------------------------------- + + def list_manifests( + self, project_id: str, *, cursor: str | None = None, page_size: int | None = None, timeout: Timeout = None + ) -> Page[Manifest]: + """One page of the project's signed chain manifests (``GET /v1/manifests``). + + Manifests carry the chain linkage (``prev_hash`` / ``head_hash``) without + the batch payloads and page in lockstep with :meth:`list_logs`: the same + cursor covers the same sequences on both endpoints. + """ + return self._list(_ops.MANIFESTS_PATH, Manifest, project_id, cursor, page_size, timeout) + + def iter_manifests( + self, project_id: str, *, page_size: int | None = None, timeout: Timeout = None + ) -> Iterator[Manifest]: + """Every chain manifest of the project, in sequence order, across all pages.""" + return self._iter_pages( + lambda cursor: self.list_manifests(project_id, cursor=cursor, page_size=page_size, timeout=timeout) + ) + + # -- checkpoints ------------------------------------------------------------- + + def list_checkpoints( + self, project_id: str, *, cursor: str | None = None, page_size: int | None = None, timeout: Timeout = None + ) -> Page[Checkpoint]: + """One page of the project's signed checkpoints, newest first (``GET /v1/checkpoints``).""" + return self._list(_ops.CHECKPOINTS_PATH, Checkpoint, project_id, cursor, page_size, timeout) + + def iter_checkpoints( + self, project_id: str, *, page_size: int | None = None, timeout: Timeout = None + ) -> Iterator[Checkpoint]: + """Every checkpoint of the project, newest first, across all pages.""" + return self._iter_pages( + lambda cursor: self.list_checkpoints(project_id, cursor=cursor, page_size=page_size, timeout=timeout) + ) + + # -- keys -------------------------------------------------------------------- + + def jwks(self, project_id: str, *, timeout: Timeout = None) -> KeySet: + """The public keys behind the batch, manifest and checkpoint signatures (``GET /.well-known/jwks.json``). + + The endpoint is public and ignores the credential this client sends + with it. ``project_id`` is required but does not select a key today. + Keep the set next to an exported trail: it names the keys the export + was signed with, and a ``kid`` missing from a later set was rotated out. + """ + return KeySet.model_validate(self._send(_ops.jwks_spec(project_id), timeout=timeout).json()) diff --git a/indykite_sdk/audit/models.py b/indykite_sdk/audit/models.py new file mode 100644 index 0000000..57e08a2 --- /dev/null +++ b/indykite_sdk/audit/models.py @@ -0,0 +1,138 @@ +"""Models for the Audit Log API (``/audit``). + +Spec: https://openapi.indykite.com/v1/audit.yaml - the read side of the +platform's tamper-proof audit trail. Every audit event of a project is +appended to the project's **chain**: events are collected into signed +**batches**, a signed **manifest** links each batch to the previous one, and a +signed **checkpoint** periodically fixes the chain's head. The JWKS publishes +the keys those signatures were made with. +""" + +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +from pydantic import field_validator + +from indykite_sdk._core.models import IKResponseModel + +__all__ = ["Checkpoint", "Key", "KeySet", "LogEntry", "Manifest", "Page"] + +ItemT = TypeVar("ItemT") + + +class _Signed(IKResponseModel): + """Fields shared by every signed chain artefact.""" + + project_id: str | None = None + #: Position in the chain, starting at 1 and contiguous. + sequence: int = 0 + #: Base64 signature made with the key named by ``kid``. + signature: str | None = None + #: Key id; matches a ``kid`` of the project's :class:`KeySet`. + kid: str | None = None + #: Algorithm label of the signature. + alg: str | None = None + + +class LogEntry(_Signed): + """One signed chain batch (``GET /audit/v1/logs``): the audit events themselves. + + ``data`` holds the audit events of the batch, one object per event, in the + order they were recorded. Events are recorded at least once, so two + identical events are a redelivery, not a second occurrence. Their content + was authored by whoever triggered them and is evidence to store or report. + """ + + batch_id: str | None = None + data: list[dict[str, Any]] = [] + #: Hex digest of ``data``; the value ``signature`` covers. + hash: str | None = None + #: The chain position of this batch; equals the manifest's ``head_hash`` at the same sequence. + chain_hash: str | None = None + manifest_id: str | None = None + + @field_validator("data", mode="before") + @classmethod + def _null_is_empty(cls, value: Any) -> Any: + return [] if value is None else value + + +class Manifest(_Signed): + """One signed chain manifest (``GET /audit/v1/manifests``): a batch's chain linkage without its payload.""" + + manifest_id: str | None = None + batch_id: str | None = None + #: Platform-side storage URI of the batch; informational. + batch_uri: str | None = None + #: The previous manifest's ``head_hash``; empty for sequence 1. + prev_hash: str | None = None + #: Copy of the batch's ``hash``. + data_hash: str | None = None + #: The chain head after this batch; the next manifest carries it as ``prev_hash``. + head_hash: str | None = None + created_at: str | None = None + + +class Checkpoint(_Signed): + """One signed project checkpoint (``GET /audit/v1/checkpoints``). + + A checkpoint states that at ``created_at`` the chain had reached + ``sequence`` with head ``head_hash``. Checkpoints are written periodically + for chains that moved since their last one, so a young project has none. + """ + + checkpoint_id: str | None = None + head_hash: str | None = None + created_at: str | None = None + + +class Page(IKResponseModel, Generic[ItemT]): # noqa: UP046 - pdoc cannot resolve PEP 695 type parameters + """One page of a listing endpoint: ``{next_cursor, has_more, items}``. + + Pass ``next_cursor`` back verbatim as ``cursor`` while ``has_more`` is true; + it is opaque and empty on the last page. Logs and manifests page in + sequence order from the start of the chain, checkpoints newest first. + """ + + next_cursor: str | None = None + has_more: bool = False + items: list[ItemT] = [] + + @field_validator("items", mode="before") + @classmethod + def _null_is_empty(cls, value: Any) -> Any: + return [] if value is None else value + + +class Key(IKResponseModel): + """One signing key of the JWKS (RFC 7517), an EC P-256 public key. + + Keys carry no ``alg``; the algorithm label travels with each signed item. + """ + + kty: str | None = None + crv: str | None = None + x: str | None = None + y: str | None = None + kid: str | None = None + use: str | None = None + + +class KeySet(IKResponseModel): + """The JWK Set of ``GET /audit/.well-known/jwks.json``.""" + + keys: list[Key] = [] + + @field_validator("keys", mode="before") + @classmethod + def _null_is_empty(cls, value: Any) -> Any: + return [] if value is None else value + + def find(self, kid: str) -> Key | None: + """The key with the given ``kid`` (as carried by a batch, manifest or checkpoint), or ``None``. + + A ``None`` for a ``kid`` seen in the trail means the key was rotated + out; keep the JWKS that was current when the trail was exported. + """ + return next((key for key in self.keys if key.kid == kid), None) diff --git a/indykite_sdk/authzen/_ops.py b/indykite_sdk/authzen/_ops.py index 8515a81..281b6d5 100644 --- a/indykite_sdk/authzen/_ops.py +++ b/indykite_sdk/authzen/_ops.py @@ -31,7 +31,12 @@ def _context_body(context: ContextInput) -> dict[str, Any] | None: def evaluation_spec( - subject: NodeInput, action: ActionInput, resource: NodeInput, context: ContextInput, user_token: str | None + subject: NodeInput, + action: ActionInput, + resource: NodeInput, + context: ContextInput, + user_token: str | None, + delegated_token: str | None, ) -> RequestSpec: """Build a single-decision request body.""" body: dict[str, Any] = { @@ -41,7 +46,7 @@ def evaluation_spec( } if (context_body := _context_body(context)) is not None: body["context"] = context_body - return RequestSpec("POST", "/evaluation", json_body=body, headers=user_token_headers(user_token)) + return RequestSpec("POST", "/evaluation", json_body=body, headers=user_token_headers(user_token, delegated_token)) def evaluations_spec( @@ -51,6 +56,7 @@ def evaluations_spec( resource: NodeInput | None, context: ContextInput, user_token: str | None, + delegated_token: str | None, ) -> RequestSpec: """Build a batch-decision request body (top-level fields act as defaults).""" if not evaluations: @@ -69,11 +75,11 @@ def evaluations_spec( body["resource"] = _coerce(resource, Node, "resource").to_wire() if (context_body := _context_body(context)) is not None: body["context"] = context_body - return RequestSpec("POST", "/evaluations", json_body=body, headers=user_token_headers(user_token)) + return RequestSpec("POST", "/evaluations", json_body=body, headers=user_token_headers(user_token, delegated_token)) def search_action_spec( - subject: NodeInput, resource: NodeInput, context: ContextInput, user_token: str | None + subject: NodeInput, resource: NodeInput, context: ContextInput, user_token: str | None, delegated_token: str | None ) -> RequestSpec: """Build a search/action request body.""" body: dict[str, Any] = { @@ -82,7 +88,9 @@ def search_action_spec( } if (context_body := _context_body(context)) is not None: body["context"] = context_body - return RequestSpec("POST", "/search/action", json_body=body, headers=user_token_headers(user_token)) + return RequestSpec( + "POST", "/search/action", json_body=body, headers=user_token_headers(user_token, delegated_token) + ) def search_resource_spec( @@ -91,6 +99,7 @@ def search_resource_spec( resource_type: NodeType | dict[str, Any] | str, context: ContextInput, user_token: str | None, + delegated_token: str | None, ) -> RequestSpec: """Build a search/resource request body.""" body: dict[str, Any] = { @@ -100,7 +109,9 @@ def search_resource_spec( } if (context_body := _context_body(context)) is not None: body["context"] = context_body - return RequestSpec("POST", "/search/resource", json_body=body, headers=user_token_headers(user_token)) + return RequestSpec( + "POST", "/search/resource", json_body=body, headers=user_token_headers(user_token, delegated_token) + ) def policies_spec(subject_type: str | None) -> RequestSpec: @@ -120,6 +131,7 @@ def search_subject_spec( subject_type: NodeType | dict[str, Any] | str, context: ContextInput, user_token: str | None, + delegated_token: str | None, ) -> RequestSpec: """Build a search/subject request body.""" body: dict[str, Any] = { @@ -129,4 +141,6 @@ def search_subject_spec( } if (context_body := _context_body(context)) is not None: body["context"] = context_body - return RequestSpec("POST", "/search/subject", json_body=body, headers=user_token_headers(user_token)) + return RequestSpec( + "POST", "/search/subject", json_body=body, headers=user_token_headers(user_token, delegated_token) + ) diff --git a/indykite_sdk/authzen/aio.py b/indykite_sdk/authzen/aio.py index 7a2d39d..7c50e7c 100644 --- a/indykite_sdk/authzen/aio.py +++ b/indykite_sdk/authzen/aio.py @@ -45,10 +45,11 @@ async def evaluation( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> EvaluationResponse: """Decide whether ``subject`` may perform ``action`` on ``resource`` (``POST /evaluation``).""" - spec = _ops.evaluation_spec(subject, action, resource, context, user_token) + spec = _ops.evaluation_spec(subject, action, resource, context, user_token, delegated_token) return EvaluationResponse.model_validate((await self._send(spec, timeout=timeout)).json()) async def evaluations( @@ -60,10 +61,11 @@ async def evaluations( resource: _ops.NodeInput | None = None, context: _ops.ContextInput = None, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> EvaluationsResponse: """Batch decisions in one call (``POST /evaluations``).""" - spec = _ops.evaluations_spec(evaluations, subject, action, resource, context, user_token) + spec = _ops.evaluations_spec(evaluations, subject, action, resource, context, user_token, delegated_token) return EvaluationsResponse.model_validate((await self._send(spec, timeout=timeout)).json()) async def search_action( @@ -73,10 +75,11 @@ async def search_action( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> ActionSearchResponse: """List the actions ``subject`` may perform on ``resource`` (``POST /search/action``).""" - spec = _ops.search_action_spec(subject, resource, context, user_token) + spec = _ops.search_action_spec(subject, resource, context, user_token, delegated_token) return ActionSearchResponse.model_validate((await self._send(spec, timeout=timeout)).json()) async def search_resource( @@ -87,10 +90,11 @@ async def search_resource( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> ResourceSearchResponse: """List the resources of ``resource_type`` on which ``subject`` may perform ``action``.""" - spec = _ops.search_resource_spec(subject, action, resource_type, context, user_token) + spec = _ops.search_resource_spec(subject, action, resource_type, context, user_token, delegated_token) return ResourceSearchResponse.model_validate((await self._send(spec, timeout=timeout)).json()) async def search_subject( @@ -101,10 +105,11 @@ async def search_subject( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> SubjectSearchResponse: """List the subjects of ``subject_type`` allowed to perform ``action`` on ``resource``.""" - spec = _ops.search_subject_spec(resource, action, subject_type, context, user_token) + spec = _ops.search_subject_spec(resource, action, subject_type, context, user_token, delegated_token) return SubjectSearchResponse.model_validate((await self._send(spec, timeout=timeout)).json()) async def policies( diff --git a/indykite_sdk/authzen/client.py b/indykite_sdk/authzen/client.py index 8706574..82523ef 100644 --- a/indykite_sdk/authzen/client.py +++ b/indykite_sdk/authzen/client.py @@ -28,9 +28,23 @@ class AuthZENClient(BaseSyncClient): Authenticates with the raw **application-agent credential token** (``INDYKITE_APPLICATION_CREDENTIALS[_FILE]``) sent as ``X-IK-ClientKey``. - Every method accepts an optional ``user_token`` - a third-party end-user - access token forwarded as ``Authorization: Bearer`` so the decision runs - in that user's context (requires a Token Introspect configuration). + Every decision and search method accepts two optional request tokens, + forwarded as headers so the policy can read their claims: + + - ``user_token`` - a third-party end-user access token, sent as + ``Authorization: Bearer`` (requires a Token Introspect configuration). + The decision runs in that user's context and the policy condition reads + its claims as ``$token``, e.g. ``$token.sub``. + - ``delegated_token`` - an IndyKite delegated token minted by the IndyKite + Token Service, sent as ``X-IK-Token``. Its claims, including the + RFC 8693 ``act`` delegation chain, are ``$ik_token`` to the policy, + e.g. ``$ik_token.act.sub``. When both tokens are supplied, their ``sub`` + claims must match. + + ``token`` and ``ik_token`` are therefore reserved names in + ``context.input_params``: a value sent under them is replaced by the real + claims, and a policy that reads a token which was not sent denies rather + than fails. Example:: @@ -52,6 +66,7 @@ def evaluation( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> EvaluationResponse: """Decide whether ``subject`` may perform ``action`` on ``resource`` (``POST /evaluation``). @@ -61,8 +76,10 @@ def evaluation( action: ``"CAN_DRIVE"``, ``{"name": ...}``, or an ``Action``. resource: same forms as ``subject``. context: optional ``{"input_params": {...}, "policy_tags": [...]}``. + user_token: optional end-user access token (``$token`` to the policy). + delegated_token: optional IndyKite delegated token (``$ik_token`` to the policy). """ - spec = _ops.evaluation_spec(subject, action, resource, context, user_token) + spec = _ops.evaluation_spec(subject, action, resource, context, user_token, delegated_token) return EvaluationResponse.model_validate(self._send(spec, timeout=timeout).json()) def evaluations( @@ -74,6 +91,7 @@ def evaluations( resource: _ops.NodeInput | None = None, context: _ops.ContextInput = None, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> EvaluationsResponse: """Batch decisions in one call (``POST /evaluations``). @@ -82,7 +100,7 @@ def evaluations( defaults; each item overrides any of them. Results come back in request order (``response.decisions``). """ - spec = _ops.evaluations_spec(evaluations, subject, action, resource, context, user_token) + spec = _ops.evaluations_spec(evaluations, subject, action, resource, context, user_token, delegated_token) return EvaluationsResponse.model_validate(self._send(spec, timeout=timeout).json()) def search_action( @@ -92,10 +110,11 @@ def search_action( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> ActionSearchResponse: """List the actions ``subject`` may perform on ``resource`` (``POST /search/action``).""" - spec = _ops.search_action_spec(subject, resource, context, user_token) + spec = _ops.search_action_spec(subject, resource, context, user_token, delegated_token) return ActionSearchResponse.model_validate(self._send(spec, timeout=timeout).json()) def search_resource( @@ -106,6 +125,7 @@ def search_resource( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> ResourceSearchResponse: """List the resources of ``resource_type`` on which ``subject`` may perform ``action``. @@ -114,7 +134,7 @@ def search_resource( client.search_resource(("Person", "ada"), "CAN_DRIVE", "Car") """ - spec = _ops.search_resource_spec(subject, action, resource_type, context, user_token) + spec = _ops.search_resource_spec(subject, action, resource_type, context, user_token, delegated_token) return ResourceSearchResponse.model_validate(self._send(spec, timeout=timeout).json()) def search_subject( @@ -125,6 +145,7 @@ def search_subject( context: _ops.ContextInput = None, *, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> SubjectSearchResponse: """List the subjects of ``subject_type`` allowed to perform ``action`` on ``resource``. @@ -133,7 +154,7 @@ def search_subject( client.search_subject(("Car", "kitt"), "CAN_DRIVE", "Person") """ - spec = _ops.search_subject_spec(resource, action, subject_type, context, user_token) + spec = _ops.search_subject_spec(resource, action, subject_type, context, user_token, delegated_token) return SubjectSearchResponse.model_validate(self._send(spec, timeout=timeout).json()) def policies( diff --git a/indykite_sdk/authzen/models.py b/indykite_sdk/authzen/models.py index 67b196a..fdcde3b 100644 --- a/indykite_sdk/authzen/models.py +++ b/indykite_sdk/authzen/models.py @@ -78,6 +78,15 @@ class Context(IKModel): ``input_params`` feeds policy input parameters; ``policy_tags`` limits evaluation to policies carrying those tags. + + The names ``token`` and ``ik_token`` are reserved: the platform binds them + to the claims of the request tokens (the ``user_token`` sent as + ``Authorization: Bearer`` and the ``delegated_token`` sent as + ``X-IK-Token``), so a policy condition reads ``$token.sub`` or + ``$ik_token.act.sub`` directly. A policy never asks for them as input + params, and a value supplied under either name is replaced by the real + claims. A token that was not sent binds an empty claim set, so a policy + reading it denies rather than fails. """ input_params: dict[str, Any] | None = None diff --git a/indykite_sdk/ciq/aio.py b/indykite_sdk/ciq/aio.py index 92734b3..8383781 100644 --- a/indykite_sdk/ciq/aio.py +++ b/indykite_sdk/ciq/aio.py @@ -38,6 +38,7 @@ async def execute( page_size: int | None = None, page_token: int | None = None, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> ExecuteResponse: """Execute one page of a knowledge query (``POST /execute``).""" @@ -45,7 +46,7 @@ async def execute( "POST", "/execute", json_body=_execute_body(query, input_params, preprocess_params, page_size, page_token), - headers=user_token_headers(user_token), + headers=user_token_headers(user_token, delegated_token), ) return ExecuteResponse.model_validate((await self._send(spec, timeout=timeout)).json()) @@ -57,6 +58,7 @@ async def execute_iter( preprocess_params: dict[str, str] | None = None, page_size: int = 100, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> AsyncIterator[ExecuteRecord]: """Iterate over all records of a query, fetching pages transparently.""" @@ -69,6 +71,7 @@ async def execute_iter( page_size=page_size, page_token=page_token, user_token=user_token, + delegated_token=delegated_token, timeout=timeout, ) for record in response.data: diff --git a/indykite_sdk/ciq/client.py b/indykite_sdk/ciq/client.py index ed7b77a..c3540cd 100644 --- a/indykite_sdk/ciq/client.py +++ b/indykite_sdk/ciq/client.py @@ -49,6 +49,16 @@ class CIQClient(BaseSyncClient): Reads and policy-mediated writes both go through :meth:`execute` — the knowledge query (created via the Config API) defines what happens. + Two optional request tokens travel as headers, never in the body, and the + CIQ policy reads their claims: the end-user access token (``user_token``, + ``Authorization: Bearer``) as ``$token``, e.g. ``$token.sub``, and the + IndyKite delegated token (``delegated_token``, ``X-IK-Token``) as + ``$ik_token``, e.g. ``$ik_token.act.sub`` for the acting agent of the + RFC 8693 delegation chain. ``token`` and ``ik_token`` are therefore + reserved names in ``input_params``: a value sent under them is replaced by + the real claims, and a token that was not sent binds an empty claim set, + so a policy reading it returns nothing rather than failing. + Example:: from indykite_sdk import CIQClient @@ -74,23 +84,29 @@ def execute( page_size: int | None = None, page_token: int | None = None, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> ExecuteResponse: """Execute one page of a knowledge query (``POST /execute``). Args: query: The knowledge query GID or name. - input_params: Values for the query's input parameters. + input_params: Values for the query's input parameters. ``token`` + and ``ik_token`` are reserved for the token claims. preprocess_params: CIQ v2 preprocess parameter values. page_size: Result-set page size (API default 100). page_token: Integer page number; values under 1 return the first page. - user_token: Optional end-user access token to run in that user's context. + user_token: Optional end-user access token to run in that user's + context; its claims are ``$token`` to the policy. + delegated_token: Optional IndyKite delegated token (minted by the + IndyKite Token Service); its claims are ``$ik_token`` to the + policy. When both tokens are supplied, their ``sub`` claims must match. """ spec = RequestSpec( "POST", "/execute", json_body=_execute_body(query, input_params, preprocess_params, page_size, page_token), - headers=user_token_headers(user_token), + headers=user_token_headers(user_token, delegated_token), ) return ExecuteResponse.model_validate(self._send(spec, timeout=timeout).json()) @@ -102,12 +118,14 @@ def execute_iter( preprocess_params: dict[str, str] | None = None, page_size: int = 100, user_token: str | None = None, + delegated_token: str | None = None, timeout: httpx.Timeout | float | None = None, ) -> Iterator[ExecuteRecord]: """Iterate over all records of a query, fetching pages transparently. Stops when a page comes back with fewer than ``page_size`` records - (the API exposes no next-page marker). + (the API exposes no next-page marker). Takes the same arguments as + :meth:`execute` except ``page_token``. """ page_token = 1 while True: @@ -118,6 +136,7 @@ def execute_iter( page_size=page_size, page_token=page_token, user_token=user_token, + delegated_token=delegated_token, timeout=timeout, ) yield from response.data diff --git a/indykite_sdk/config/client.py b/indykite_sdk/config/client.py index dd3dd8b..2287b36 100644 --- a/indykite_sdk/config/client.py +++ b/indykite_sdk/config/client.py @@ -216,9 +216,12 @@ def create_application_agent( ) -> CreateResult: """Create an application agent restricted to the given API permissions. - ``api_permissions`` values: ``Authorization``, ``Capture``, ``ContXIQ``, - ``EntityMatching``, ``ReadAuthZConfigs`` (lets the agent list the + ``api_permissions`` values: ``Audit`` (read the project's tamper-proof + audit trail via the Audit Log API), ``Authorization``, ``Capture``, + ``ContXIQ``, ``EntityMatching``, ``ReadAuthZConfigs`` (list the project's authorization policies via the AuthZEN API), ``ReadDataSchema``. + A permission granted or changed later takes a moment to reach the data + plane, so a just-updated agent can briefly keep answering 401. """ body = { "name": name, diff --git a/indykite_sdk/config/models/core.py b/indykite_sdk/config/models/core.py index 4f02210..3960e36 100644 --- a/indykite_sdk/config/models/core.py +++ b/indykite_sdk/config/models/core.py @@ -31,8 +31,14 @@ "ServiceAccountRole", ] -#: API permission grantable to an application agent. -ApiPermission = Literal["Authorization", "Capture", "ContXIQ", "EntityMatching", "ReadAuthZConfigs", "ReadDataSchema"] +#: API permission grantable to an application agent. Each guards one data-plane API: +#: ``Audit`` the Audit Log API (``/audit/v1``), ``Authorization`` AuthZEN decisions and +#: searches, ``Capture`` the Capture API, ``ContXIQ`` knowledge-query execution, +#: ``EntityMatching`` the Entity Matching API, ``ReadAuthZConfigs`` the AuthZEN policy +#: listing and ``ReadDataSchema`` the Data Schema API. +ApiPermission = Literal[ + "Audit", "Authorization", "Capture", "ContXIQ", "EntityMatching", "ReadAuthZConfigs", "ReadDataSchema" +] #: Lifecycle status of policies and knowledge queries. ConfigStatus = Literal["ACTIVE", "INACTIVE", "DRAFT"] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 70d47ee..5853c69 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -12,9 +12,11 @@ - ``INDYKITE_TEST_ENTITY_MATCHING_PIPELINE_ID`` — an entity-matching pipeline The application agent must belong to ``INDYKITE_TEST_PROJECT_ID`` and hold the -``Authorization``, ``Capture``, ``ContXIQ`` and ``ReadAuthZConfigs`` API -permissions: the policy listing and whoami tests create their fixtures in that -project with the service account and read them back through the agent. +``Audit``, ``Authorization``, ``Capture``, ``ContXIQ``, ``EntityMatching``, +``ReadAuthZConfigs`` and ``ReadDataSchema`` API permissions: the policy listing +and whoami tests create their fixtures in that project with the service account +and read them back through the agent, and the audit test reads that project's +audit trail. Tests skip themselves when their prerequisites are missing, so a partial environment still runs what it can. @@ -29,7 +31,15 @@ import pytest -from indykite_sdk import AuthZENClient, CaptureClient, CIQClient, ConfigClient, DataSchemaClient, EntityMatchingClient +from indykite_sdk import ( + AuditClient, + AuthZENClient, + CaptureClient, + CIQClient, + ConfigClient, + DataSchemaClient, + EntityMatchingClient, +) _INTEGRATION_DIR = os.path.dirname(__file__) @@ -141,6 +151,14 @@ def data_schema_client() -> Iterator[DataSchemaClient]: yield client +@pytest.fixture +def audit_client() -> Iterator[AuditClient]: + """An AuditClient authenticated from the environment.""" + require_env("INDYKITE_APPLICATION_CREDENTIALS", "INDYKITE_APPLICATION_CREDENTIALS_FILE") + with AuditClient() as client: + yield client + + @pytest.fixture def entity_matching_client() -> Iterator[EntityMatchingClient]: """An EntityMatchingClient authenticated from the environment.""" diff --git a/tests/integration/test_audit.py b/tests/integration/test_audit.py new file mode 100644 index 0000000..3c1d9c5 --- /dev/null +++ b/tests/integration/test_audit.py @@ -0,0 +1,62 @@ +"""Live Audit Log API smoke tests. + +The agent must hold the ``Audit`` API permission. The chain of the test project +may be empty, so the tests assert the envelope and the cross-endpoint +invariants that hold for any chain length, not a particular content. +""" + +from __future__ import annotations + +from indykite_sdk import AuditClient +from indykite_sdk.audit import Checkpoint, LogEntry, Manifest + + +def test_jwks_returns_signature_keys(audit_client: AuditClient, project_id: str) -> None: + """Jwks returns signature keys.""" + keys = audit_client.jwks(project_id) + assert keys.keys, "the platform publishes at least one signing key" + for key in keys.keys: + assert key.kty == "EC" + assert key.kid + + +def test_logs_and_manifests_page_in_lockstep(audit_client: AuditClient, project_id: str) -> None: + """Logs and manifests page in lockstep.""" + logs = audit_client.list_logs(project_id, page_size=5) + manifests = audit_client.list_manifests(project_id, page_size=5) + assert isinstance(logs.has_more, bool) + # The chain may grow between the two calls, so compare only the sequences both pages cover. + manifests_by_sequence = {manifest.sequence: manifest for manifest in manifests.items} + for batch in logs.items: + assert isinstance(batch, LogEntry) + assert isinstance(batch.data, list) + manifest = manifests_by_sequence.get(batch.sequence) + if manifest is None: + continue + assert isinstance(manifest, Manifest) + assert batch.batch_id == manifest.batch_id + assert batch.hash == manifest.data_hash + assert batch.chain_hash == manifest.head_hash + + +def test_manifests_chain_is_linked(audit_client: AuditClient, project_id: str) -> None: + """Manifests chain is linked.""" + manifests = list(audit_client.iter_manifests(project_id, page_size=50)) + previous_head = "" + for expected_sequence, manifest in enumerate(manifests, start=1): + assert manifest.sequence == expected_sequence + assert (manifest.prev_hash or "") == previous_head + assert manifest.signature and manifest.kid + previous_head = manifest.head_hash or "" + + +def test_checkpoints_are_newest_first_and_within_the_chain(audit_client: AuditClient, project_id: str) -> None: + """Checkpoints are newest first and within the chain.""" + checkpoints = list(audit_client.iter_checkpoints(project_id, page_size=10)) + chain_length = sum(1 for _ in audit_client.iter_manifests(project_id)) + sequences = [checkpoint.sequence for checkpoint in checkpoints] + assert sequences == sorted(sequences, reverse=True) + for checkpoint in checkpoints: + assert isinstance(checkpoint, Checkpoint) + assert 1 <= checkpoint.sequence <= chain_length + assert checkpoint.head_hash and checkpoint.signature and checkpoint.created_at diff --git a/tests/unit/audit/__init__.py b/tests/unit/audit/__init__.py new file mode 100644 index 0000000..843a0fa --- /dev/null +++ b/tests/unit/audit/__init__.py @@ -0,0 +1 @@ +"""Test package: audit log.""" diff --git a/tests/unit/audit/test_audit.py b/tests/unit/audit/test_audit.py new file mode 100644 index 0000000..a13d17c --- /dev/null +++ b/tests/unit/audit/test_audit.py @@ -0,0 +1,281 @@ +"""Audit Log client: query parameters, envelope parsing, cursor paging, JWKS.""" + +from __future__ import annotations + +import pytest + +from indykite_sdk import AsyncAuditClient, AuditClient, IndyKiteError, RequestValidationError +from indykite_sdk.audit import Checkpoint, KeySet, LogEntry, Manifest, Page + +PROJECT = "gid:AAAAAmluZHlraURlgAABDwAAAAA" + +BATCH = { + "batch_id": "batch-1", + "project_id": PROJECT, + "sequence": 1, + "data": [{"type": "indykite.audit.capture.upsert", "actor": "agent-1"}, {"type": "indykite.audit.authz"}], + "hash": "abc", + "signature": "c2ln", + "kid": "platform-key-1", + "alg": "ES256", + "chain_hash": "head-1", + "manifest_id": "manifest-1", +} + +MANIFEST = { + "manifest_id": "manifest-1", + "batch_id": "batch-1", + "batch_uri": "gs://bucket/gid.AAAA/batches/1.json", + "project_id": PROJECT, + "sequence": 1, + "prev_hash": "", + "data_hash": "abc", + "head_hash": "head-1", + "signature": "c2ln", + "kid": "platform-key-1", + "alg": "ES256", + "created_at": "2026-09-21T12:00:00Z", +} + +CHECKPOINT = { + "checkpoint_id": "checkpoint-1", + "project_id": PROJECT, + "sequence": 1, + "head_hash": "head-1", + "created_at": "2026-09-21T13:00:00.000000001Z", + "signature": "c2ln", + "kid": "platform-key-1", + "alg": "ES256", +} + +JWKS = {"keys": [{"kty": "EC", "crv": "P-256", "x": "eA", "y": "eQ", "use": "sig", "kid": "platform-key-1"}]} + +EMPTY_PAGE = {"next_cursor": "", "has_more": False, "items": []} + + +def test_list_logs_request_and_parsing(make_client, mock_api) -> None: + """List logs request and parsing.""" + mock_api.respond({"next_cursor": "MTAx", "has_more": True, "items": [BATCH]}) + client = make_client(AuditClient) + page = client.list_logs(PROJECT, cursor="MTAw", page_size=10) + assert mock_api.last.method == "GET" + assert mock_api.last.url.path == "/audit/v1/logs" + assert dict(mock_api.last.url.params) == {"project_id": PROJECT, "cursor": "MTAw", "pagesize": "10"} + assert mock_api.last.headers["X-IK-ClientKey"] == "app-agent-token-value" + assert isinstance(page, Page) + assert page.has_more is True + assert page.next_cursor == "MTAx" + batch = page.items[0] + assert isinstance(batch, LogEntry) + assert batch.sequence == 1 + assert batch.kid == "platform-key-1" + assert batch.chain_hash == "head-1" + assert len(batch.data) == 2 + assert batch.data[0]["actor"] == "agent-1" + + +def test_list_logs_first_page_sends_only_project_id(make_client, mock_api) -> None: + """List logs first page sends only project id.""" + mock_api.respond(EMPTY_PAGE) + client = make_client(AuditClient) + page = client.list_logs(PROJECT) + assert dict(mock_api.last.url.params) == {"project_id": PROJECT} + assert page.items == [] + assert page.has_more is False + + +def test_list_manifests(make_client, mock_api) -> None: + """List manifests.""" + mock_api.respond({"next_cursor": "", "has_more": False, "items": [MANIFEST]}) + client = make_client(AuditClient) + page = client.list_manifests(PROJECT) + assert mock_api.last.url.path == "/audit/v1/manifests" + manifest = page.items[0] + assert isinstance(manifest, Manifest) + assert manifest.prev_hash == "" + assert manifest.head_hash == "head-1" + assert manifest.data_hash == "abc" + assert manifest.created_at.startswith("2026-09-21") + + +def test_list_checkpoints(make_client, mock_api) -> None: + """List checkpoints.""" + mock_api.respond({"next_cursor": "", "has_more": False, "items": [CHECKPOINT]}) + client = make_client(AuditClient) + page = client.list_checkpoints(PROJECT, page_size=1) + assert mock_api.last.url.path == "/audit/v1/checkpoints" + assert mock_api.last.url.params["pagesize"] == "1" + checkpoint = page.items[0] + assert isinstance(checkpoint, Checkpoint) + assert checkpoint.checkpoint_id == "checkpoint-1" + assert checkpoint.head_hash == "head-1" + + +def test_null_wire_fields_parse(make_client, mock_api) -> None: + """Null wire fields parse.""" + mock_api.respond({"next_cursor": None, "has_more": False, "items": [{**MANIFEST, "prev_hash": None}]}) + mock_api.respond({"next_cursor": None, "has_more": False, "items": None}) + mock_api.respond({"next_cursor": None, "has_more": False, "items": [{**BATCH, "data": None, "kid": None}]}) + mock_api.respond({"keys": None}) + client = make_client(AuditClient) + manifests = client.list_manifests(PROJECT) + assert manifests.next_cursor is None + assert manifests.items[0].prev_hash is None + assert client.list_checkpoints(PROJECT).items == [] + batch = client.list_logs(PROJECT).items[0] + assert batch.data == [] + assert batch.kid is None + assert client.jwks(PROJECT).keys == [] + + +def test_iter_handles_null_next_cursor_on_last_page(make_client, mock_api) -> None: + """Iter handles null next cursor on last page.""" + mock_api.respond({"next_cursor": "c1", "has_more": True, "items": [BATCH]}) + mock_api.respond({"next_cursor": None, "has_more": False, "items": [{**BATCH, "sequence": 2}]}) + client = make_client(AuditClient) + assert [batch.sequence for batch in client.iter_logs(PROJECT)] == [1, 2] + + +def test_page_tolerates_unknown_fields(make_client, mock_api) -> None: + """Page tolerates unknown fields.""" + mock_api.respond({"next_cursor": "", "has_more": False, "items": [{**BATCH, "new_field": 1}], "total": 1}) + client = make_client(AuditClient) + page = client.list_logs(PROJECT) + assert page.items[0].batch_id == "batch-1" + + +def test_iter_logs_follows_cursor_until_has_more_is_false(make_client, mock_api) -> None: + """Iter logs follows cursor until has more is false.""" + mock_api.respond({"next_cursor": "c1", "has_more": True, "items": [BATCH, {**BATCH, "sequence": 2}]}) + mock_api.respond({"next_cursor": "c2", "has_more": True, "items": [{**BATCH, "sequence": 3}]}) + mock_api.respond({"next_cursor": "", "has_more": False, "items": [{**BATCH, "sequence": 4}]}) + client = make_client(AuditClient) + batches = list(client.iter_logs(PROJECT, page_size=2)) + assert [batch.sequence for batch in batches] == [1, 2, 3, 4] + assert [request.url.params.get("cursor") for request in mock_api.requests] == [None, "c1", "c2"] + assert all(request.url.params["pagesize"] == "2" for request in mock_api.requests) + + +def test_iter_manifests_single_page(make_client, mock_api) -> None: + """Iter manifests single page.""" + mock_api.respond({"next_cursor": "", "has_more": False, "items": [MANIFEST]}) + client = make_client(AuditClient) + assert [manifest.manifest_id for manifest in client.iter_manifests(PROJECT)] == ["manifest-1"] + assert len(mock_api.requests) == 1 + + +def test_iter_checkpoints_empty_chain(make_client, mock_api) -> None: + """Iter checkpoints empty chain.""" + mock_api.respond(EMPTY_PAGE) + client = make_client(AuditClient) + assert list(client.iter_checkpoints(PROJECT)) == [] + + +def test_iter_stops_when_cursor_does_not_advance(make_client, mock_api) -> None: + """Iter stops when cursor does not advance.""" + mock_api.respond({"next_cursor": "same", "has_more": True, "items": [BATCH]}) + mock_api.respond({"next_cursor": "same", "has_more": True, "items": [{**BATCH, "sequence": 2}]}) + client = make_client(AuditClient) + seen: list[int] = [] + # The first page advances (None -> "same"); the second hands the same cursor back. + with pytest.raises(IndyKiteError, match="does not advance"): + for batch in client.iter_logs(PROJECT): + seen.append(batch.sequence) + assert seen == [1, 2] + assert len(mock_api.requests) == 2 + + +def test_iter_stops_when_has_more_without_cursor(make_client, mock_api) -> None: + """Iter stops when has more without cursor.""" + mock_api.respond({"next_cursor": "", "has_more": True, "items": [BATCH]}) + client = make_client(AuditClient) + with pytest.raises(IndyKiteError, match="does not advance"): + list(client.iter_logs(PROJECT)) + + +@pytest.mark.parametrize("project_id", ["", " "]) +def test_project_id_required(make_client, mock_api, project_id) -> None: + """Project id required.""" + client = make_client(AuditClient) + with pytest.raises(RequestValidationError, match="project_id"): + client.list_logs(project_id) + with pytest.raises(RequestValidationError, match="project_id"): + client.jwks(project_id) + assert mock_api.requests == [] + + +def test_project_id_is_stripped(make_client, mock_api) -> None: + """Project id is stripped.""" + mock_api.respond(EMPTY_PAGE) + client = make_client(AuditClient) + client.list_manifests(f" {PROJECT} ") + assert mock_api.last.url.params["project_id"] == PROJECT + + +@pytest.mark.parametrize("page_size", [0, -5]) +def test_page_size_must_be_positive(make_client, mock_api, page_size) -> None: + """Page size must be positive.""" + client = make_client(AuditClient) + with pytest.raises(RequestValidationError, match="page_size"): + client.list_checkpoints(PROJECT, page_size=page_size) + assert mock_api.requests == [] + + +def test_jwks_request_and_find(make_client, mock_api) -> None: + """Jwks request and find.""" + mock_api.respond(JWKS) + client = make_client(AuditClient) + keys = client.jwks(PROJECT) + assert mock_api.last.method == "GET" + assert mock_api.last.url.path == "/audit/.well-known/jwks.json" + assert dict(mock_api.last.url.params) == {"project_id": PROJECT} + assert isinstance(keys, KeySet) + key = keys.find("platform-key-1") + assert key is not None + assert (key.kty, key.crv, key.use) == ("EC", "P-256", "sig") + assert keys.find("rotated-out") is None + + +def test_jwks_empty_set(make_client, mock_api) -> None: + """Jwks empty set.""" + mock_api.respond({}) + client = make_client(AuditClient) + assert client.jwks(PROJECT).keys == [] + + +def test_base_url_has_audit_prefix(make_client) -> None: + """Base url has audit prefix.""" + client = make_client(AuditClient) + assert client.base_url.endswith("/audit") + + +async def test_async_list_and_iter(make_async_client, mock_api) -> None: + """Async list and iter.""" + mock_api.respond({"next_cursor": "c1", "has_more": True, "items": [BATCH]}) + mock_api.respond({"next_cursor": "", "has_more": False, "items": [{**BATCH, "sequence": 2}]}) + mock_api.respond({"next_cursor": "", "has_more": False, "items": [MANIFEST]}) + mock_api.respond({"next_cursor": "", "has_more": False, "items": [CHECKPOINT]}) + mock_api.respond(EMPTY_PAGE) + mock_api.respond(EMPTY_PAGE) + mock_api.respond(JWKS) + async with make_async_client(AsyncAuditClient) as client: + batches = [batch async for batch in client.iter_logs(PROJECT)] + manifests = (await client.list_manifests(PROJECT)).items + checkpoints = (await client.list_checkpoints(PROJECT)).items + assert [manifest async for manifest in client.iter_manifests(PROJECT)] == [] + assert [checkpoint async for checkpoint in client.iter_checkpoints(PROJECT)] == [] + keys = await client.jwks(PROJECT) + assert [batch.sequence for batch in batches] == [1, 2] + assert mock_api.requests[1].url.params["cursor"] == "c1" + assert manifests[0].head_hash == "head-1" + assert checkpoints[0].sequence == 1 + assert keys.find("platform-key-1") is not None + + +async def test_async_iter_guards_against_stuck_cursor(make_async_client, mock_api) -> None: + """Async iter guards against stuck cursor.""" + mock_api.respond({"next_cursor": "same", "has_more": True, "items": []}) + mock_api.respond({"next_cursor": "same", "has_more": True, "items": []}) + async with make_async_client(AsyncAuditClient) as client: + with pytest.raises(IndyKiteError, match="does not advance"): + _ = [batch async for batch in client.iter_logs(PROJECT)] diff --git a/tests/unit/authzen/test_authzen.py b/tests/unit/authzen/test_authzen.py index 8d477a0..ed5d3ee 100644 --- a/tests/unit/authzen/test_authzen.py +++ b/tests/unit/authzen/test_authzen.py @@ -54,6 +54,47 @@ def test_evaluation_context_and_user_token(make_client, mock_api) -> None: assert mock_api.last.headers["X-IK-ClientKey"] == "app-agent-token-value" +def test_evaluation_sends_delegated_token_header(make_client, mock_api) -> None: + """Evaluation sends delegated token header.""" + client = make_client(AuthZENClient) + client.evaluation(("Person", "ada"), "CAN_DRIVE", ("Car", "kitt"), user_token="user-jwt", delegated_token="ik") + assert mock_api.last.headers["Authorization"] == "Bearer user-jwt" + assert mock_api.last.headers["X-IK-Token"] == "ik" + assert "context" not in sent_json(mock_api.last) + + +def test_delegated_token_alone_sends_only_ik_token_header(make_client, mock_api) -> None: + """Delegated token alone sends only ik token header.""" + client = make_client(AuthZENClient) + client.evaluation(("Person", "ada"), "CAN_DRIVE", ("Car", "kitt"), delegated_token="ik") + assert mock_api.last.headers["X-IK-Token"] == "ik" + assert "Authorization" not in mock_api.last.headers + + +def test_reserved_claim_params_are_passed_through_for_the_platform_to_replace(make_client, mock_api) -> None: + """Reserved claim params are passed through for the platform to replace.""" + client = make_client(AuthZENClient) + client.evaluation( + ("Person", "ada"), "CAN_DRIVE", ("Car", "kitt"), context={"input_params": {"ik_token": {"act": {"sub": "x"}}}} + ) + assert sent_json(mock_api.last)["context"] == {"input_params": {"ik_token": {"act": {"sub": "x"}}}} + + +@pytest.mark.parametrize("method", ["evaluations", "search_action", "search_resource", "search_subject"]) +def test_every_decision_method_forwards_delegated_token(make_client, mock_api, method) -> None: + """Every decision method forwards delegated token.""" + mock_api.respond({"evaluations": [], "results": []}) + client = make_client(AuthZENClient) + call = { + "evaluations": lambda: client.evaluations([{"resource": ("Car", "kitt")}], delegated_token="ik"), + "search_action": lambda: client.search_action(("Person", "ada"), ("Car", "kitt"), delegated_token="ik"), + "search_resource": lambda: client.search_resource(("Person", "ada"), "CAN_DRIVE", "Car", delegated_token="ik"), + "search_subject": lambda: client.search_subject(("Car", "kitt"), "CAN_DRIVE", "Person", delegated_token="ik"), + }[method] + call() + assert mock_api.last.headers["X-IK-Token"] == "ik" + + def test_evaluation_default_decision_false(make_client, mock_api) -> None: """Evaluation default decision false.""" mock_api.respond({}) diff --git a/tests/unit/ciq/test_ciq.py b/tests/unit/ciq/test_ciq.py index 0813e5a..a2b3fcc 100644 --- a/tests/unit/ciq/test_ciq.py +++ b/tests/unit/ciq/test_ciq.py @@ -41,6 +41,34 @@ def test_execute_full_body(make_client, mock_api) -> None: assert mock_api.last.headers["Authorization"] == "Bearer user-jwt" +def test_execute_sends_delegated_token_header(make_client, mock_api) -> None: + """Execute sends delegated token header.""" + client = make_client(CIQClient) + client.execute("gid:query-1", user_token="user-jwt", delegated_token="ik-jwt") + assert mock_api.last.headers["Authorization"] == "Bearer user-jwt" + assert mock_api.last.headers["X-IK-Token"] == "ik-jwt" + assert mock_api.last.headers["X-IK-ClientKey"] == "app-agent-token-value" + # The tokens travel as headers only; the body never carries them. + assert sent_json(mock_api.last) == {"id": "gid:query-1"} + + +def test_execute_without_tokens_sends_no_token_headers(make_client, mock_api) -> None: + """Execute without tokens sends no token headers.""" + client = make_client(CIQClient) + client.execute("gid:query-1", delegated_token="") + assert "Authorization" not in mock_api.last.headers + assert "X-IK-Token" not in mock_api.last.headers + + +def test_execute_iter_forwards_delegated_token(make_client, mock_api) -> None: + """Execute iter forwards delegated token.""" + mock_api.respond({"data": [RECORD, RECORD]}) + mock_api.respond({"data": []}) + client = make_client(CIQClient) + list(client.execute_iter("gid:query-1", page_size=2, delegated_token="ik-jwt")) + assert all(request.headers["X-IK-Token"] == "ik-jwt" for request in mock_api.requests) + + def test_execute_empty_result(make_client, mock_api) -> None: """Execute empty result.""" mock_api.respond({}) @@ -128,5 +156,6 @@ async def test_async_ciq_execute_iter(make_async_client, mock_api) -> None: mock_api.respond({"data": [RECORD, RECORD]}) mock_api.respond({"data": []}) async with make_async_client(AsyncCIQClient) as client: - records = [record async for record in client.execute_iter("gid:query-1", page_size=2)] + records = [record async for record in client.execute_iter("gid:query-1", page_size=2, delegated_token="ik")] assert len(records) == 2 + assert all(request.headers["X-IK-Token"] == "ik" for request in mock_api.requests) diff --git a/tests/unit/test_parity.py b/tests/unit/test_parity.py index da1f600..16bfce5 100644 --- a/tests/unit/test_parity.py +++ b/tests/unit/test_parity.py @@ -14,6 +14,7 @@ (indykite_sdk.CIQClient, indykite_sdk.AsyncCIQClient), (indykite_sdk.DataSchemaClient, indykite_sdk.AsyncDataSchemaClient), (indykite_sdk.EntityMatchingClient, indykite_sdk.AsyncEntityMatchingClient), + (indykite_sdk.AuditClient, indykite_sdk.AsyncAuditClient), (indykite_sdk.ConfigClient, indykite_sdk.AsyncConfigClient), ]