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),
]