From 93e59e7230ef65f06d015e41827f7c185c3e5d72 Mon Sep 17 00:00:00 2001 From: Hacks4Snacks Date: Tue, 28 Jul 2026 10:24:32 -0500 Subject: [PATCH 1/2] use an alias to maintain a stable flow identity --- docs/cli-reference.md | 11 +- examples/webshop.tm7 | 2 +- .../Authoring/AuthoringSupport.cs | 47 +++++ .../Authoring/ManifestFlow.cs | 7 + .../Authoring/ManifestSupport.cs | 103 +++++++++- .../DeterministicGuid.cs | 17 ++ .../ApplyExportTest.cs | 191 ++++++++++++++++++ 7 files changed, 374 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ccd4acd..7fbaf2b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -878,7 +878,7 @@ source of truth for the `.tm7`. `apply` materializes it; `export` emits it from { "alias": "DB", "kind": "store", "stencil": "azure-sql", "name": "Orders DB", "boundary": "TB1" } ], "flows": [ - { "from": "API", "to": "DB", "name": "store order", + { "alias": "F1", "from": "API", "to": "DB", "name": "store order", "props": { "DataType": "Customer Content", "Protocol": "SQL", "Port": "1433" } } ] } @@ -888,8 +888,17 @@ source of truth for the `.tm7`. `apply` materializes it; `export` emits it from inside it so trust-boundary crossings are computed and membership round-trips through `export`. - `elements[].alias` gives each element a **deterministic** id (stable across rebuilds), and flows reference elements by that alias (or by unique name). +- `flows[].alias` does the same for a flow. It is optional: a flow without one still gets a stable id, + derived from its endpoints and name. Declaring an alias is what lets a flow be **renamed or + re-pointed without moving its id** — worth doing for any flow whose findings you expect to track. - Either `kind` or `stencil` identifies an element; a stencil's base primitive sets the kind. +**Every identifier `apply` assigns is derived, never minted**, so applying one manifest twice produces +the same model: the same page, component, and connector ids. That is what keeps finding ids +(`{ruleId}:{diagram}:{target}:{occurrence}`), threat-register triage, and `tmforge diff` aligned +across rebuilds. Aliases are the durable form of that identity; the structural fallback is stable +against re-applying a manifest, but renaming an alias-less object moves its id. + ### `apply` Build a model from a manifest. The whole model is built in memory and written **atomically**, so a diff --git a/examples/webshop.tm7 b/examples/webshop.tm7 index 469fc35..7b6db68 100644 --- a/examples/webshop.tm7 +++ b/examples/webshop.tm7 @@ -1,4 +1,4 @@ -DRAWINGSURFACE48761fb5-0b8e-409c-b560-15593131c0b5NameNameDiagram 1DRAWINGSURFACEb9408f4c-4b7d-595a-94c7-1c4f2a42061fGE.TB.Bb9408f4c-4b7d-595a-94c7-1c4f2a42061fGeneric Trust BoundaryNameNamePublic InternetAlias:internetGE.TB.B15640040408e132002e-eb40-5889-a5c2-f74a7c550d12GE.TB.Be132002e-eb40-5889-a5c2-f74a7c550d12Generic Trust BoundaryNameNameCloud VNetAlias:vnetGE.TB.B240400236408423917a4-d589-50ee-9a7f-8fccd14b47ffGE.EI423917a4-d589-50ee-9a7f-8fccd14b47ffGeneric External InteractorNameNameCustomer BrowserBoundary:internetAlias:customerAuthenticatesItselfAuthenticatesItselfSelectYesNo1AuthenticationSchemeAuthenticationSchemeSelectNoneBasicOAuthRBACCertificateManagedIdentityTokenPublicKey3GE.EI6064088120b88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3GE.Pb88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3Generic ProcessNameNameWeb AppBoundary:vnetAlias:webRunningAsRunningAsSelectStandard UserService AccountSystemRoot/Admin2AuthenticationSchemeAuthenticationSchemeSelectNoneBasicOAuthRBACCertificateManagedIdentity3IsolationIsolationSelectNoneProcessContainerVM3SanitizesInputSanitizesInputSelectYesNo1SanitizesOutputSanitizesOutputSelectYesNo1GE.P60640284100c3a9cb95-bf75-5003-801f-7b8173898070GE.Pc3a9cb95-bf75-5003-801f-7b8173898070Generic ProcessNameNameOrders APIBoundary:vnetAlias:apiRunningAsRunningAsSelectStandard UserService AccountSystemRoot/Admin2AuthenticationSchemeAuthenticationSchemeSelectNoneBasicOAuthRBACCertificateManagedIdentity4IsolationIsolationSelectNoneProcessContainerVM3SanitizesInputSanitizesInputSelectYesNo1SanitizesOutputSanitizesOutputSelectYesNo1GE.P601840284100c5adf20f-68d4-5971-a9d3-76a250ef77f0GE.DSc5adf20f-68d4-5971-a9d3-76a250ef77f0Generic Data StoreNameNameOrders DatabaseBoundary:vnetAlias:dbEncryptedEncryptedSelectNoAt-restTDEClient-sidePlatform2AlgorithmAlgorithmSelectNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOther2AccessControlAccessControlSelectNoneRBACACLPublic2BackupBackupSelectYesNo1StoresLogDataStoresLogDataSelectYesNo2GE.DS5030402841206b3c361c-b7be-5f01-be4b-0cd6e29c6686GE.DS6b3c361c-b7be-5f01-be4b-0cd6e29c6686Generic Data StoreNameNameAudit LogBoundary:vnetAlias:auditEncryptedEncryptedSelectNoAt-restTDEClient-sidePlatform2AccessControlAccessControlSelectNoneRBACACLPublic2StoresLogDataStoresLogDataSelectYesNo1BackupBackupSelectYesNo1GE.DS50640368120
Diagram 1
6bf80ac0-f21b-46b4-ae4f-bafb8beef13aGE.DF6bf80ac0-f21b-46b4-ae4f-bafb8beef13aGeneric Data FlowNameNameBrowse & checkoutPort:443ProtocolProtocolSelectHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther1DataTypeDataTypeSelectPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata4GE.DF119216NoneNone423917a4-d589-50ee-9a7f-8fccd14b47ff122148b88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3116284ef0a687f-4de0-4254-b66a-9961174973f8GE.DFef0a687f-4de0-4254-b66a-9961174973f8Generic Data FlowNameNamePlace orderPort:443ProtocolProtocolSelectHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther1DataTypeDataTypeSelectPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata4GE.DF174314NoneNoneb88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3164314c3a9cb95-bf75-5003-801f-7b8173898070184314ae496442-0d02-472d-9502-427a73d5683eGE.DFae496442-0d02-472d-9502-427a73d5683eGeneric Data FlowNameNameRead/write ordersProtocolProtocolSelectHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther3DataTypeDataTypeSelectPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata4AlgorithmAlgorithmSelectNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOther2GE.DF294311NoneNonec3a9cb95-bf75-5003-801f-7b8173898070284312c5adf20f-68d4-5971-a9d3-76a250ef77f03043110def4011-aa7c-4c02-b813-9e4d08efb6fcGE.DF0def4011-aa7c-4c02-b813-9e4d08efb6fcGeneric Data FlowNameNameWrite audit eventsProtocolProtocolSelectHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther3DataTypeDataTypeSelectPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata12GE.DF175356NoneNonec3a9cb95-bf75-5003-801f-7b81738980701923446b3c361c-b7be-5f01-be4b-0cd6e29c66861593680
Webshop — Threat Model Forge sample4.3falsefalseSelectStandard UserService AccountSystemRoot/AdminRunningAsVirtualDynamicRunningAsListfalseSelectNoneBasicOAuthRBACCertificateManagedIdentityAuthenticationSchemeVirtualDynamicAuthenticationSchemeListfalseSelectNoneProcessContainerVMIsolationVirtualDynamicIsolationListfalseSelectYesNoSanitizesInputVirtualDynamicSanitizesInputListfalseSelectYesNoSanitizesOutputVirtualDynamicSanitizesOutputListA representation of a generic process.falseGE.PCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Generic ProcessROOTEllipsefalsefalseSelectYesNoAuthenticatesItselfVirtualDynamicAuthenticatesItselfListfalseSelectNoneBasicOAuthRBACCertificateManagedIdentityTokenPublicKeyAuthenticationSchemeVirtualDynamicAuthenticationSchemeListA representation of a generic external interactor.falseGE.EILower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Generic External InteractorROOTRectanglefalsefalseSelectNoAt-restTDEClient-sidePlatformEncryptedVirtualDynamicEncryptedListfalseSelectNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOtherAlgorithmVirtualDynamicAlgorithmListfalseSelectNoneRBACACLPublicAccessControlVirtualDynamicAccessControlListfalseSelectYesNoSignedVirtualDynamicSignedListfalseSelectYesNoStoresCredentialsVirtualDynamicStoresCredentialsListfalseSelectYesNoStoresLogDataVirtualDynamicStoresLogDataListfalseSelectYesNoBackupVirtualDynamicBackupListA representation of a generic data store.falseGE.DSLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Generic Data StoreROOTParallelLinesfalsefalseSelectHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOtherProtocolVirtualDynamicProtocolListfalseSelectNetworkIn-ProcessLocal-fileUnix-socketLoopbackChannelVirtualDynamicChannelListfalseSelectPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem MetadataDataTypeVirtualDynamicDataTypeListfalseSelectNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOtherAlgorithmVirtualDynamicAlgorithmListfalseSelectYesNoCachedVirtualDynamicCachedListA representation of a generic data flow.falseGE.DFBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABsklEQVR42u3csRWCQBBAQQuxCEIboRAKsZErhEJsBA1I1KcBcOetO8HPlZ2nLKCnYRhOypuDAICDAIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAMTtDEDOxkfLWokKwSC3t7x0i4jAILd/7C//gMAwt1c+IFjWrwcAIAAgO4IrAPk2gtcKABB0uyYa3LFd1k0gzIZgaHVWxDAIDKwegjnChmBYyddEQ0q+JtY6G9Zzpdc1sdUapO+VfwBgkPsaIwM4G+DupuifAMUQN/ez6wM1TgCL3pp7HL41sN09gm6vDBpQ8ruEhlSvKcItYoNqf/Vv6um1Glbyx8QMrN1t4C6fETS4hM8AAJBozQPAw6AAGD4ALX4b2OWaB0Db3waO0d6LYR6z798iDh+AY84DpiHwn0QYor+IcRAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgA/aQ7lYBAqyzxlaoAAAAASUVORK5CYII=Generic Data FlowROOTLinefalseA representation of a trust boundary drawn as a line.falseGE.TB.LBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABgklEQVR42u3ZQZHFIABEQYQgAjMIiS1kxQjkwCGFBKYPz8BufyoMpbVWlJs/AgD+CAAIAAEgAASAABAAAkAACAABIAAEgAAQADdWAcisf83dACCvedQByPz1R58CAAAAAAAAAAAAAAAAAAAAdgAALIEAeAsAQAA4AQDwDQCAWwAAdgAAAAAAAAAAAAAAAAAAAAAA7AAAWAIB8BYAgABwAgDgGwAAtwAA7AAAAAAAAAAAAAAAAAAAAAAA2AEAsAQC4C0AAAHgBADANwAAbgEA2AEAAAAAAAAAAAAAAAAAAAAAsAMAYAkEwFsAAALACQCAbwAA3AIAsAMAAAAAAAAAAAAAAAAAAAAAYAcAwBIIgLcAAASAEwAA3wAAuAUAYAcAAAAAAAAAAAAAAAAAAAAAwA4AgCUQAG8BAAgAJwAAvgEAcAuwA9gBAAAAAAAAACD26ncCeADIavz++W/qHpA+AvX9y692AAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAXdECTu9QuZj9SCgAAAAASUVORK5CYII=Generic Trust Line BoundaryROOTLineBoundaryfalseA representation of a trust boundary drawn as a border.falseGE.TB.BBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCGeneric Trust Border BoundaryROOTBorderBoundaryfalseA representation of a free-text annotation.falseGE.ABefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABS0lEQVR42u3by6mFMABFUQtJEWkmhaStlGUjig4EEcd+zhqsAl7cPBK4Z6q1TuRyCAJwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAI4E4LV1ID2P74hd1IDMCHP2tJARQf/KKn/QcYPvphfuou8IYL4AjXn7wIegp5BjoEASAABIAAEAACQAAIAAEgAASAABAAAkAACAAB2AXYBdgF2AXYBdgF2AXYBdgF2AXYBdgF4BmIABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAA/rELKAKwCxgCsAtoAsjeBXQB5O4C5q/eBewCPv67fs9ABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAJwCAJAAAgAASAABIAAEAACQAAIAAEgAATAD60tWKBL95yRYwAAAABJRU5ErkJggg==Generic AnnotationROOTAnnotationThreat Model Forge7e3f1d52-0000-4000-8000-746d666f7267Threat Model Forge Core1.0.0falseA person interacting with the systemfalsehuman-actorLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Human ActorGE.EIRectanglefalseManaged web app / API hostfalseazure-app-serviceCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure App ServiceGE.PEllipsefalseServerless computefalseazure-functionCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure FunctionsGE.PEllipsefalseManaged Kubernetesfalseazure-aksCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure Kubernetes ServiceGE.PEllipsefalseIaaS computefalseazure-vmCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure Virtual MachineGE.PEllipsefalseManaged relational databasefalseazure-sqlLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure SQL DatabaseGE.DSParallelLinesfalseGlobally distributed NoSQLfalseazure-cosmosLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Cosmos DBGE.DSParallelLinesfalseObject storagefalseazure-blobLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Blob StorageGE.DSParallelLinesfalseMessage broker / queuefalseazure-service-busLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Service BusGE.DSParallelLinesfalseIdentity providerfalseentra-idLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Microsoft Entra IDGE.EIRectanglefalseSecrets, keys, and certificatesfalseazure-key-vaultLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Key VaultGE.DSParallelLinesfalseWorkload identity principalfalsemanaged-identityLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Managed IdentityGE.EIRectanglefalseNetwork isolation boundaryfalseazure-vnetBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCAzure Virtual NetworkGE.TB.BBorderBoundaryfalseSubnet boundaryfalseazure-subnetBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCSubnetGE.TB.BBorderBoundaryfalseEntra ID tenant trust boundary (customer / directory)falseazure-tenantBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCMicrosoft Entra TenantGE.TB.BBorderBoundaryfalseSubscription trust and billing boundaryfalseazure-subscriptionBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCAzure SubscriptionGE.TB.BBorderBoundaryfalseL7 load balancer with WAFfalseapp-gatewayCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Application Gateway / WAFGE.PEllipsefalsePrivate network ingressfalseprivate-endpointCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Private EndpointGE.PEllipsefalseCluster trust boundary (control plane + nodes)falsek8s-clusterBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCKubernetes ClusterGE.TB.BBorderBoundaryfalseSoft isolation boundary within a clusterfalsek8s-namespaceBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCNamespaceGE.TB.BBorderBoundaryfalseWorker node host that runs podsfalsek8s-nodeBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCNode / Node PoolGE.TB.BBorderBoundaryfalseControl-plane API (authN/authZ, RBAC)falsek8s-api-serverCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Kube API ServerGE.PEllipsefalseScheduled workload of one or more containersfalsek8s-podCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=PodGE.PEllipsefalseSidecar container or service-mesh proxyfalsek8s-sidecarCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Sidecar / Mesh ProxyGE.PEllipsefalseL7 ingress and TLS terminationfalsek8s-ingressCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Ingress ControllerGE.PEllipsefalseInternal load balancer / service routingfalsek8s-serviceCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=ServiceGE.PEllipsefalseCluster state and Secrets storefalsek8s-etcdLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=etcdGE.DSParallelLinesfalseNamespaced secret (base64, not encrypted by default)falsek8s-secretLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Kubernetes SecretGE.DSParallelLinesfalseNamespaced non-secret configurationfalsek8s-configmapLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=ConfigMapGE.DSParallelLinesfalseContainer image registry (ACR, GHCR, Docker Hub)falsecontainer-registryLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Container RegistryGE.DSParallelLinesfalsePod identity for API and cloud accessfalsek8s-service-accountLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Service Account / Workload IdentityGE.EIRectanglefalseIssued X.509 certificate (cert-manager Certificate CRD, materialized as a Secret)falsecert-manager-certificateLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=cert-manager CertificateGE.DSParallelLinesfalseIssues scoped tokens for principals (STS / token service)falsesecurity-token-serviceCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Security Token ServiceGE.PEllipsefalseFederated workload principal (short-lived, no stored secret)falseworkload-identityLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Workload IdentityGE.EIRectanglefalseWeb browser clientfalsebrowserLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==BrowserGE.EIRectanglefalseNative mobile clientfalsemobile-appLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Mobile AppGE.EIRectanglefalseExternal API dependencyfalsethird-party-apiLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Third-party APIGE.EIRectanglefalseSImpersonating something or someone else.SpoofingImpersonating something or someone else.falseTUnauthorized modification of data or code.TamperingUnauthorized modification of data or code.falseRDenying an action without other parties being able to prove otherwise.RepudiationDenying an action without other parties being able to prove otherwise.falseIExposure of information to parties not authorized to see it.Information DisclosureExposure of information to parties not authorized to see it.falseDDenying or degrading service to legitimate users.Denial of ServiceDenying or degrading service to legitimate users.falseEGaining capabilities without proper authorization.Elevation of PrivilegeGaining capabilities without proper authorization.falseISet Cached = No on the flow and read the credential fresh from the store on each use, so a rotated or revoked credential is never served from a stale cache. If a short-lived cache is unavoidable, bound its lifetime to the credential's rotation interval.TM1027A flow that reads a credential from a store that holds credentials and caches it can serve a stale credential after the credential is rotated or revoked, and the cached copy widens the window during which a leaked credential remains usable (tampering and elevation of privilege via a stale key). +DRAWINGSURFACEeb4f2d30-e264-5f85-8764-18b5c0d09384NameNameDiagram 1DRAWINGSURFACEb9408f4c-4b7d-595a-94c7-1c4f2a42061fGE.TB.Bb9408f4c-4b7d-595a-94c7-1c4f2a42061fGeneric Trust BoundaryNameNamePublic InternetAlias:internetGE.TB.B15640040408e132002e-eb40-5889-a5c2-f74a7c550d12GE.TB.Be132002e-eb40-5889-a5c2-f74a7c550d12Generic Trust BoundaryNameNameCloud VNetAlias:vnetGE.TB.B240400236408423917a4-d589-50ee-9a7f-8fccd14b47ffGE.EI423917a4-d589-50ee-9a7f-8fccd14b47ffGeneric External InteractorNameNameCustomer BrowserBoundary:internetAlias:customerAuthenticatesItselfAuthenticatesItselfSelectUnknownYesNo2AuthenticationSchemeAuthenticationSchemeSelectUnknownNoneBasicOAuthRBACCertificateManagedIdentityTokenPublicKey4GE.EI6064088120b88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3GE.Pb88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3Generic ProcessNameNameWeb AppBoundary:vnetAlias:webRunningAsRunningAsSelectUnknownStandard UserService AccountSystemRoot/Admin3AuthenticationSchemeAuthenticationSchemeSelectUnknownNoneBasicOAuthRBACCertificateManagedIdentity4IsolationIsolationSelectUnknownNoneProcessContainerVM4SanitizesInputSanitizesInputSelectUnknownYesNo2SanitizesOutputSanitizesOutputSelectUnknownYesNo2GE.P60640284100c3a9cb95-bf75-5003-801f-7b8173898070GE.Pc3a9cb95-bf75-5003-801f-7b8173898070Generic ProcessNameNameOrders APIBoundary:vnetAlias:apiRunningAsRunningAsSelectUnknownStandard UserService AccountSystemRoot/Admin3AuthenticationSchemeAuthenticationSchemeSelectUnknownNoneBasicOAuthRBACCertificateManagedIdentity5IsolationIsolationSelectUnknownNoneProcessContainerVM4SanitizesInputSanitizesInputSelectUnknownYesNo2SanitizesOutputSanitizesOutputSelectUnknownYesNo2GE.P601840284100c5adf20f-68d4-5971-a9d3-76a250ef77f0GE.DSc5adf20f-68d4-5971-a9d3-76a250ef77f0Generic Data StoreNameNameOrders DatabaseBoundary:vnetAlias:dbEncryptedEncryptedSelectUnknownNoAt-restTDEClient-sidePlatform3AlgorithmAlgorithmSelectUnknownNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOther3AccessControlAccessControlSelectUnknownNoneRBACACLPublic3BackupBackupSelectUnknownYesNo2StoresLogDataStoresLogDataSelectUnknownYesNo3GE.DS5030402841206b3c361c-b7be-5f01-be4b-0cd6e29c6686GE.DS6b3c361c-b7be-5f01-be4b-0cd6e29c6686Generic Data StoreNameNameAudit LogBoundary:vnetAlias:auditEncryptedEncryptedSelectUnknownNoAt-restTDEClient-sidePlatform3AccessControlAccessControlSelectUnknownNoneRBACACLPublic3StoresLogDataStoresLogDataSelectUnknownYesNo2BackupBackupSelectUnknownYesNo2GE.DS50640368120
Diagram 1
d95d5119-b3ab-5bfb-80c2-8eb444edb01dGE.DFd95d5119-b3ab-5bfb-80c2-8eb444edb01dGeneric Data FlowNameNameBrowse & checkoutPort:443ProtocolProtocolSelectUnknownHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther2DataTypeDataTypeSelectUnknownPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata5GE.DF119216NoneNone423917a4-d589-50ee-9a7f-8fccd14b47ff122148b88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3116284a628a6b8-8c87-530c-a46c-8b7add5ea15dGE.DFa628a6b8-8c87-530c-a46c-8b7add5ea15dGeneric Data FlowNameNamePlace orderPort:443ProtocolProtocolSelectUnknownHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther2DataTypeDataTypeSelectUnknownPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata5GE.DF174314NoneNoneb88bf985-a80d-55b3-b3b1-5e4d7b5c9bd3164314c3a9cb95-bf75-5003-801f-7b8173898070184314825c26eb-9eb8-5cf3-8644-a7269ef92d77GE.DF825c26eb-9eb8-5cf3-8644-a7269ef92d77Generic Data FlowNameNameRead/write ordersProtocolProtocolSelectUnknownHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther4DataTypeDataTypeSelectUnknownPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata5AlgorithmAlgorithmSelectUnknownNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOther3GE.DF294311NoneNonec3a9cb95-bf75-5003-801f-7b8173898070284312c5adf20f-68d4-5971-a9d3-76a250ef77f030431162797d92-5efc-50ef-8791-e79b79634168GE.DF62797d92-5efc-50ef-8791-e79b79634168Generic Data FlowNameNameWrite audit eventsProtocolProtocolSelectUnknownHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOther4DataTypeDataTypeSelectUnknownPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem Metadata13GE.DF175356NoneNonec3a9cb95-bf75-5003-801f-7b81738980701923446b3c361c-b7be-5f01-be4b-0cd6e29c66861593680
Webshop — Threat Model Forge sample4.3falsefalseSelectUnknownStandard UserService AccountSystemRoot/AdminRunningAsVirtualDynamicRunningAsListfalseSelectUnknownNoneBasicOAuthRBACCertificateManagedIdentityAuthenticationSchemeVirtualDynamicAuthenticationSchemeListfalseSelectUnknownNoneProcessContainerVMIsolationVirtualDynamicIsolationListfalseSelectUnknownYesNoSanitizesInputVirtualDynamicSanitizesInputListfalseSelectUnknownYesNoSanitizesOutputVirtualDynamicSanitizesOutputListA representation of a generic process.falseGE.PCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Generic ProcessROOTEllipsefalsefalseSelectUnknownYesNoAuthenticatesItselfVirtualDynamicAuthenticatesItselfListfalseSelectUnknownNoneBasicOAuthRBACCertificateManagedIdentityTokenPublicKeyAuthenticationSchemeVirtualDynamicAuthenticationSchemeListA representation of a generic external interactor.falseGE.EILower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Generic External InteractorROOTRectanglefalsefalseSelectUnknownNoAt-restTDEClient-sidePlatformEncryptedVirtualDynamicEncryptedListfalseSelectUnknownNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOtherAlgorithmVirtualDynamicAlgorithmListfalseSelectUnknownNoneRBACACLPublicAccessControlVirtualDynamicAccessControlListfalseSelectUnknownYesNoSignedVirtualDynamicSignedListfalseSelectUnknownYesNoStoresCredentialsVirtualDynamicStoresCredentialsListfalseSelectUnknownYesNoStoresLogDataVirtualDynamicStoresLogDataListfalseSelectUnknownYesNoBackupVirtualDynamicBackupListA representation of a generic data store.falseGE.DSLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Generic Data StoreROOTParallelLinesfalsefalseSelectUnknownHTTPSHTTPTLSmTLSgRPCAMQPSQLSSHFTPOtherProtocolVirtualDynamicProtocolListfalseSelectUnknownNetworkIn-ProcessLocal-fileUnix-socketLoopbackChannelVirtualDynamicChannelListfalseSelectUnknownPublic Non-Personal DataPublic Personal DataAccount DataCustomer ContentEUIIEUPIOIIAccess Control DataSupport DataFeedbackManaged Service DataSystem MetadataDataTypeVirtualDynamicDataTypeListfalseSelectUnknownNoneAES-GCMAES-CBC+HMACChaCha20-Poly1305AES-CBC3DESRC4secretboxOtherAlgorithmVirtualDynamicAlgorithmListfalseSelectUnknownYesNoCachedVirtualDynamicCachedListA representation of a generic data flow.falseGE.DFBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABsklEQVR42u3csRWCQBBAQQuxCEIboRAKsZErhEJsBA1I1KcBcOetO8HPlZ2nLKCnYRhOypuDAICDAIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAMTtDEDOxkfLWokKwSC3t7x0i4jAILd/7C//gMAwt1c+IFjWrwcAIAAgO4IrAPk2gtcKABB0uyYa3LFd1k0gzIZgaHVWxDAIDKwegjnChmBYyddEQ0q+JtY6G9Zzpdc1sdUapO+VfwBgkPsaIwM4G+DupuifAMUQN/ez6wM1TgCL3pp7HL41sN09gm6vDBpQ8ruEhlSvKcItYoNqf/Vv6um1Glbyx8QMrN1t4C6fETS4hM8AAJBozQPAw6AAGD4ALX4b2OWaB0Db3waO0d6LYR6z798iDh+AY84DpiHwn0QYor+IcRAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgA/aQ7lYBAqyzxlaoAAAAASUVORK5CYII=Generic Data FlowROOTLinefalseA representation of a trust boundary drawn as a line.falseGE.TB.LBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABgklEQVR42u3ZQZHFIABEQYQgAjMIiS1kxQjkwCGFBKYPz8BufyoMpbVWlJs/AgD+CAAIAAEgAASAABAAAkAACAABIAAEgAAQADdWAcisf83dACCvedQByPz1R58CAAAAAAAAAAAAAAAAAAAAdgAALIEAeAsAQAA4AQDwDQCAWwAAdgAAAAAAAAAAAAAAAAAAAAAA7AAAWAIB8BYAgABwAgDgGwAAtwAA7AAAAAAAAAAAAAAAAAAAAAAA2AEAsAQC4C0AAAHgBADANwAAbgEA2AEAAAAAAAAAAAAAAAAAAAAAsAMAYAkEwFsAAALACQCAbwAA3AIAsAMAAAAAAAAAAAAAAAAAAAAAYAcAwBIIgLcAAASAEwAA3wAAuAUAYAcAAAAAAAAAAAAAAAAAAAAAwA4AgCUQAG8BAAgAJwAAvgEAcAuwA9gBAAAAAAAAACD26ncCeADIavz++W/qHpA+AvX9y692AAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAXdECTu9QuZj9SCgAAAAASUVORK5CYII=Generic Trust Line BoundaryROOTLineBoundaryfalseA representation of a trust boundary drawn as a border.falseGE.TB.BBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCGeneric Trust Border BoundaryROOTBorderBoundaryfalseA representation of a free-text annotation.falseGE.ABefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABS0lEQVR42u3by6mFMABFUQtJEWkmhaStlGUjig4EEcd+zhqsAl7cPBK4Z6q1TuRyCAJwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAI4E4LV1ID2P74hd1IDMCHP2tJARQf/KKn/QcYPvphfuou8IYL4AjXn7wIegp5BjoEASAABIAAEAACQAAIAAEgAASAABAAAkAACAAB2AXYBdgF2AXYBdgF2AXYBdgF2AXYBdgF4BmIABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAA/rELKAKwCxgCsAtoAsjeBXQB5O4C5q/eBewCPv67fs9ABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAJwCAJAAAgAASAABIAAEAACQAAIAAEgAATAD60tWKBL95yRYwAAAABJRU5ErkJggg==Generic AnnotationROOTAnnotationThreat Model Forge7e3f1d52-0000-4000-8000-746d666f7267Threat Model Forge Core1.0.0falseA person interacting with the systemfalsehuman-actorLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Human ActorGE.EIRectanglefalseManaged web app / API hostfalseazure-app-serviceCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure App ServiceGE.PEllipsefalseServerless computefalseazure-functionCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure FunctionsGE.PEllipsefalseManaged Kubernetesfalseazure-aksCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure Kubernetes ServiceGE.PEllipsefalseIaaS computefalseazure-vmCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Azure Virtual MachineGE.PEllipsefalseManaged relational databasefalseazure-sqlLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure SQL DatabaseGE.DSParallelLinesfalseGlobally distributed NoSQLfalseazure-cosmosLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Cosmos DBGE.DSParallelLinesfalseObject storagefalseazure-blobLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Blob StorageGE.DSParallelLinesfalseMessage broker / queuefalseazure-service-busLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Service BusGE.DSParallelLinesfalseIdentity providerfalseentra-idLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Microsoft Entra IDGE.EIRectanglefalseSecrets, keys, and certificatesfalseazure-key-vaultLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Azure Key VaultGE.DSParallelLinesfalseWorkload identity principalfalsemanaged-identityLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Managed IdentityGE.EIRectanglefalseNetwork isolation boundaryfalseazure-vnetBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCAzure Virtual NetworkGE.TB.BBorderBoundaryfalseSubnet boundaryfalseazure-subnetBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCSubnetGE.TB.BBorderBoundaryfalseEntra ID tenant trust boundary (customer / directory)falseazure-tenantBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCMicrosoft Entra TenantGE.TB.BBorderBoundaryfalseSubscription trust and billing boundaryfalseazure-subscriptionBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCAzure SubscriptionGE.TB.BBorderBoundaryfalseL7 load balancer with WAFfalseapp-gatewayCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Application Gateway / WAFGE.PEllipsefalsePrivate network ingressfalseprivate-endpointCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Private EndpointGE.PEllipsefalseCluster trust boundary (control plane + nodes)falsek8s-clusterBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCKubernetes ClusterGE.TB.BBorderBoundaryfalseSoft isolation boundary within a clusterfalsek8s-namespaceBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCNamespaceGE.TB.BBorderBoundaryfalseWorker node host that runs podsfalsek8s-nodeBefore labeliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAACDUlEQVR42u3ZwY3DIBRF0RRCEV66EQpJIWmEQlKIG0kUKdFYjO3geCYCcRZ3+SQbrhD8dxqG4YR+sQgEsAgEAAFAABAABAABQAAQAAQAAUAAEAAEAAFAABAABAABQAAQAAT4TdxA7ngu1CrA4+NuBUS5w7lUowC3Hcgdz8WaBIg7f/R1jAW5j3OpVQHyD09yH+WqF2B6fuScuJFPG/Seu7YoQPLMGqpfXwIQgAAEOM7SrfVs4/oRIL+1Tn89sULdc4C5qWeb/++nQJWTQHyHarsAdN4GhtnQIlrc/k6AKbukjDarnzvAuHBLvdi0fl4BBkEmgQQgAAEIQAACEIAABNAGagO1gTZOGwhtILSB0AZCGwhtILSBMAkEAUAAEAAEgDYQ2kBoA6ENhDYQ2kBoA6ENhDYQJoEgAAgAAhCAAATQBhJAG2gOoA10CmgDdQEEgDbQCaANdAfQBnoFGASZAxCAAAQgAAEIQAACEIAA2kACaAPNAbSBJoHKIF0AAaANdAJoA90BtIFeAQZB5gAEIAABCPAVAV6vgfDm7hBXkPvh0qIAWx8bFl4NcuW55gTIb62p8CflGhEgFHzwvB287vhRufe56trAnLwdjIU/KVeWq6oNTBlr7eD4vNykFeT25ZRBIAAIAAKAACAACAACgAAgAAgAAoAAIAAIAAKAACAAASwCAUAAEAAEQH/cAdm5ooak7huiAAAAAElFTkSuQmCCNode / Node PoolGE.TB.BBorderBoundaryfalseControl-plane API (authN/authZ, RBAC)falsek8s-api-serverCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Kube API ServerGE.PEllipsefalseScheduled workload of one or more containersfalsek8s-podCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=PodGE.PEllipsefalseSidecar container or service-mesh proxyfalsek8s-sidecarCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Sidecar / Mesh ProxyGE.PEllipsefalseL7 ingress and TLS terminationfalsek8s-ingressCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Ingress ControllerGE.PEllipsefalseInternal load balancer / service routingfalsek8s-serviceCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=ServiceGE.PEllipsefalseCluster state and Secrets storefalsek8s-etcdLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=etcdGE.DSParallelLinesfalseNamespaced secret (base64, not encrypted by default)falsek8s-secretLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Kubernetes SecretGE.DSParallelLinesfalseNamespaced non-secret configurationfalsek8s-configmapLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=ConfigMapGE.DSParallelLinesfalseContainer image registry (ACR, GHCR, Docker Hub)falsecontainer-registryLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=Container RegistryGE.DSParallelLinesfalsePod identity for API and cloud accessfalsek8s-service-accountLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Service Account / Workload IdentityGE.EIRectanglefalseIssued X.509 certificate (cert-manager Certificate CRD, materialized as a Secret)falsecert-manager-certificateLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLklEQVR42u3dQQ3AIBAAQYQg4swgBFvIqpE2ldAELqTMY/4NbBrgcyUiCueyCAKwCAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEACnBdBYqu4awPtxNynGjgHYmFxtpwCqDUnXd/sDDJuS5pp1FlhxABws1WceBF2FXAMtggAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAA/CkAc33MDCLMDCLMDCLMDCLMDCLMDMI7AAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAAC4JsH/5GP0LSbdGAAAAAASUVORK5CYII=cert-manager CertificateGE.DSParallelLinesfalseIssues scoped tokens for principals (STS / token service)falsesecurity-token-serviceCentered on stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAESUlEQVR42u2dwZHkIAxFJxCC6COJEAiBkAiBEIgTme2tclfNod3GbZAEvMO/bNXs2OghfYS8+/N4PH7QumIRAIBFAAAEAAgAEAAgAEAAgAAAAQACAAQACABmlwOAOeWfCk/lXdtTvxf0+rm4/z0AYFz/g5SeKhcDfUXbDkWYJXPMEPTcMeBnKnuGcAAgW7OTYtA/weABoG9NLw3Sd43u/p4IALqBL3uWaFGrwx7QPCMI1lN9ubDYScit+z2wV54tAMA1pQtHNW/Aj2yVWckBwPnu2ipTqzN4IqnJChEA3itOYq5qPEu2ArCVWn9msNKgPYrtBGi/OgDuZJFM1s3GfiasCkAYqVZ29jZ5NQDC5Lv+m2yQVwEgWNwJq76/pZcPCwS/xvvkWQEg+AYhkHxZgn8NgjgLAJ9ecuXgm1gfiRfMBP9rCLbeJyKt9i7Br4egjAqAX/io19oopxEBEKd5Eh1lTT8SAEcdL0eAq1QO/MAQALgFevtax+Y4AgCF1N+1FDjLAHhSf/fNlCwDUB5zDHNYukbuuqF6P+xGELsY6mQRgILxEzWEzhIAjt0vngWiJQASu188C2yWAMD565wIggUAAv1+tXuCbAGALNW3Rm/vV9QBwPzpmsGgCUCg8aPeGMqaAKQHwx7SappxezhTgiQ/Yue0AODWz8YtYdAAwFP/zfiApAFAoP6b8QFZA4CmtQjpeK/WABAcPSMoDsBGA8hUQ8hLA0D/39a9QAAAAAAAAAAAAAAAAOgNgAMAcwBEMgAZAAAAAAAAAAAAQAMAZgEGnAngMojLIAAAAAOjSUin/JpoRiC9JpyZ0SSkM4pn7lMlJPspHt8FLv59oJlLCaRzCdfjgWgIyTaAoiYARyPKjIfLjYM7bQAiXwipfRF0O9v2qkuMiMuMgkcLABylJrqCfbt/TUqt6fSE+pfZ3udTegLG17Y3pWQB4+sqQSpe4J7B7ppZJWjlRNDW+TfNqlL1ir5AG2Pd3FdJXVdiCNtspObX7ZItS0rBvdTfpcUueWtFKbiX+rvcskofXzgVfOf6ux2nNaZXuC28XjrdqACo/afIk4x6dc+YmjUNCM6D390zaU6yAsHn4ItMWFt40ZUhUF+T5V6Y4OsCcPbiYZGjXrGyEazRP/tYuT84FallQYsp8NX4mK0kpA/vq1YCLQ47zJYNPu36l9tXg93Kx47bSTbwg9b6dAK4+t3ICMbo72KNUhbiCdRmDO9IdXIEEM4Cb87fjFgz/y5kMPK8NeCa9DSjTcMepdMk7BPc/nyl8hmz1aw1g5F6t9ixMRBuzza5MjsNY2BnctSfgpD3nw+VivvP5Bu/c4iTy4hHq3hxF0oqjXZkHf0fS8oGgl52KIfsXM7SZv2mPt9RHjnoMwLwzrClRhni5SHCjN82rDZufWT43v35EuvCPN7iYhEAgEUAAAQACAAQACAAQACAAAABAAIABABobv0DNL570ikMFSQAAAAASUVORK5CYII=Security Token ServiceGE.PEllipsefalseFederated workload principal (short-lived, no stored secret)falseworkload-identityLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Workload IdentityGE.EIRectanglefalseWeb browser clientfalsebrowserLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==BrowserGE.EIRectanglefalseNative mobile clientfalsemobile-appLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Mobile AppGE.EIRectanglefalseExternal API dependencyfalsethird-party-apiLower right of stenciliVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABY0lEQVR42u3dQQ2FQAxAwRWCiJpBCEIwghCEYARCggN2N4Wdw7uTdv4PnFoiomjcDAEAQwBAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIDGBDCraVNWAPfDnerSlhGAxfRtzgTAr/+j/wIAANAMwPE8pN63fxHA5jMr0s8XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsDgDLA0AACAABIAAEgAAQAAJAAAgAASAABIAAEAD6EYA93Pir1RqORgkApQUwWUj3lkwAyiPSYvp0RKUbwi3eVt35a9sSFQ9I+8QaPEMAwBAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAGg/F1jM5Ra2Fp0XwAAAABJRU5ErkJggg==Third-party APIGE.EIRectanglefalseSImpersonating something or someone else.SpoofingImpersonating something or someone else.falseTUnauthorized modification of data or code.TamperingUnauthorized modification of data or code.falseRDenying an action without other parties being able to prove otherwise.RepudiationDenying an action without other parties being able to prove otherwise.falseIExposure of information to parties not authorized to see it.Information DisclosureExposure of information to parties not authorized to see it.falseDDenying or degrading service to legitimate users.Denial of ServiceDenying or degrading service to legitimate users.falseEGaining capabilities without proper authorization.Elevation of PrivilegeGaining capabilities without proper authorization.truefalseTitlefalsetmforge:title0UserThreatCategoryfalsetmforge:category0UserThreatShortDescriptiontruetmforge:short-description1UserThreatDescriptionfalsetmforge:description0StateInformationfalsetmforge:state-information0InteractionStringfalsetmforge:interaction0PriorityfalseCriticalHighMediumLowGenerated threat priority.tmforge:priority1falseISet Cached = No on the flow and read the credential fresh from the store on each use, so a rotated or revoked credential is never served from a stale cache. If a short-lived cache is unavoidable, bound its lifetime to the credential's rotation interval.TM1027A flow that reads a credential from a store that holds credentials and caches it can serve a stale credential after the credential is rotated or revoked, and the cached copy widens the window during which a leaked credential remains usable (tampering and elevation of privilege via a stale key). This rule is violated when a flow connected to a credential store (StoresCredentials = Yes) is marked Cached = Yes. It does not fire for flows that read the credential fresh on each use.falseIUse an encrypted transport for the flow and update the edge Protocol property accordingly (for example HTTPS instead of HTTP, FTPS or SFTP instead of FTP, LDAPS instead of LDAP, or SSH instead of Telnet). Where possible, require mutual TLS across trust boundaries. diff --git a/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs b/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs index 677cc3a..18ac103 100644 --- a/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs +++ b/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs @@ -225,6 +225,22 @@ public static bool TryResolveElementId( /// A deterministic GUID for the alias. public static Guid DeterministicId(string alias) => DeterministicGuid.FromElementId(alias); + /// + /// Derives a stable, deterministic page GUID from a page id, in its own namespace so a page and + /// an element that happen to share an id do not collide. + /// + /// The page id. + /// A deterministic GUID for the page. + public static Guid DeterministicPageId(string pageId) => DeterministicGuid.FromPageId(pageId); + + /// + /// Derives a stable identifier for an object with no author-supplied alias, in its own namespace + /// so it can never collide with an alias. + /// + /// The structural key describing the object. + /// A deterministic GUID for the key. + public static Guid DeterministicStructuralId(string key) => DeterministicGuid.FromStructuralKey(key); + /// /// Re-keys a component to a new GUID within its diagram, updating both the dictionary key and /// the entity. Used to give an aliased element its deterministic id after it is created. @@ -251,6 +267,37 @@ public static void RekeyComponent(DrawingSurfaceModel diagram, Guid current, Gui } } + /// + /// Re-keys a connector to a new GUID within its diagram. Connectors live in a separate + /// collection from components, so re-keying one needs its own routine. + /// + /// The diagram containing the connector. + /// The connector's current GUID. + /// The desired GUID. + public static void RekeyConnector(DrawingSurfaceModel diagram, Guid current, Guid desired) + { + if (diagram == null) + { + throw new ArgumentNullException(nameof(diagram)); + } + + if (current == desired) + { + return; + } + + if (diagram.Lines.TryGetValue(current, out object? line)) + { + diagram.Lines.Remove(current); + if (line is Connector connector) + { + connector.Guid = desired; + } + + diagram.Lines[desired] = line!; + } + } + /// /// Computes a non-overlapping position for the -th element placed /// inside a trust boundary, laying members out in a three-column grid inset from the boundary's diff --git a/src/ThreatModelForge.Engine/Authoring/ManifestFlow.cs b/src/ThreatModelForge.Engine/Authoring/ManifestFlow.cs index f7a4fa6..0418c50 100644 --- a/src/ThreatModelForge.Engine/Authoring/ManifestFlow.cs +++ b/src/ThreatModelForge.Engine/Authoring/ManifestFlow.cs @@ -5,6 +5,13 @@ namespace ThreatModelForge.Engine /// A data flow between two elements in a . public sealed class ManifestFlow { + /// + /// Gets or sets the flow's stable alias. Like an element alias this fixes the flow's identity, + /// so re-applying a manifest reproduces the same connector id instead of a fresh one — which is + /// what keeps finding ids, threat-register keys, and diffs aligned across runs. + /// + public string? Alias { get; set; } + /// Gets or sets the source element reference (alias or unique name). public string? From { get; set; } diff --git a/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs b/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs index 3aee7bd..2990c02 100644 --- a/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs +++ b/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs @@ -2,6 +2,7 @@ namespace ThreatModelForge.Engine { using System; using System.Collections.Generic; + using System.Globalization; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -18,6 +19,9 @@ namespace ThreatModelForge.Engine /// public static class ManifestSupport { + /// The page a manifest builds onto. Also the key its deterministic id derives from. + private const string DefaultPageName = "Diagram 1"; + private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -58,7 +62,17 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o error = null; summary = default; model = new ThreatModel { Version = "1.0" }; - DrawingSurfaceModel diagram = new DrawingSurfaceModel { Guid = Guid.NewGuid(), Header = "Diagram 1" }; + + // Every identifier this method assigns is derived, never minted. Applying a manifest + // rebuilds the whole model, so a fresh guid anywhere means two applies of one manifest + // share no identity: finding ids (which embed the page and the target), threat-register + // keys, and structural diffs all move even though nothing about the model changed. + DrawingSurfaceModel diagram = new DrawingSurfaceModel + { + Guid = AuthoringSupport.DeterministicPageId(DefaultPageName), + Header = DefaultPageName, + }; + model.DrawingSurfaceList.Add(diagram); if (!string.IsNullOrWhiteSpace(manifest.Name)) { @@ -72,6 +86,7 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o DiagramEditor editor = new DiagramEditor(model); HashSet aliasIds = new HashSet(); + HashSet structuralKeys = new HashSet(StringComparer.Ordinal); Dictionary memberCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (ManifestElement element in elements.Where(element => !string.IsNullOrEmpty(element.Boundary))) @@ -108,6 +123,10 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o boxes[boundary.Alias!] = new BoundaryBox(x, y); } + else + { + AuthoringSupport.RekeyComponent(diagram, id, StructuralId("boundary:" + boundary.Name, structuralKeys)); + } cursorY = y + height + 40; } @@ -159,6 +178,11 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o { return false; } + + if (string.IsNullOrEmpty(element.Alias)) + { + AuthoringSupport.RekeyComponent(diagram, id, StructuralId("element:" + name, structuralKeys)); + } } foreach (ManifestFlow flow in flows) @@ -199,6 +223,33 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o { return false; } + + if (!string.IsNullOrEmpty(flow.Alias)) + { + if (!TryAssignFlowAlias(diagram, id, flow.Alias!, aliasIds, out error)) + { + return false; + } + } + else + { + string key = "flow:" + flow.From + ">" + flow.To + ":" + flow.Name; + AuthoringSupport.RekeyConnector(diagram, id, StructuralId(key, structuralKeys)); + } + } + + // Identifiers are derived, and every object is stored under its own. If two ever derived the + // same one, the second would overwrite the first in the diagram's dictionaries and simply be + // missing from the model while the summary below still counted it. Checking what was built + // against what was declared turns that into a failure instead of silent loss. + int expectedComponents = boundaries.Count + elements.Count; + if (diagram.Borders.Count != expectedComponents || diagram.Lines.Count != flows.Count) + { + error = "Internal error: the manifest declared " + expectedComponents.ToString(CultureInfo.InvariantCulture) + + " components and " + flows.Count.ToString(CultureInfo.InvariantCulture) + " flows, but the model holds " + + diagram.Borders.Count.ToString(CultureInfo.InvariantCulture) + " and " + + diagram.Lines.Count.ToString(CultureInfo.InvariantCulture) + ". Two objects resolved to one identifier."; + return false; } summary = new ManifestSummary(boundaries.Count, elements.Count, flows.Count); @@ -245,9 +296,11 @@ public static Manifest Extract(ThreatModel model) foreach (Connector connector in surface.Lines.Values.OfType()) { - Dictionary userProps = FilterProps(DiagramElementHelper.GetCustomProperties(connector)); + IReadOnlyDictionary connectorProps = DiagramElementHelper.GetCustomProperties(connector); + Dictionary userProps = FilterProps(connectorProps); flows.Add(new ManifestFlow { + Alias = connectorProps.TryGetValue(AuthoringSupport.AliasPropertyName, out string? flowAlias) ? flowAlias : null, From = ReferenceFor(surface, connector.SourceGuid), To = ReferenceFor(surface, connector.TargetGuid), Name = NullIfEmpty(DiagramElementHelper.GetName(connector)), @@ -326,6 +379,52 @@ private static bool TryAssignAlias(DrawingSurfaceModel diagram, Guid current, st return true; } + /// + /// Gives a connector its deterministic id from a declared alias, mirroring . + /// Flow aliases share the element alias space because both resolve through the same reference + /// syntax, so a flow may not take an alias an element already holds. + /// + private static bool TryAssignFlowAlias(DrawingSurfaceModel diagram, Guid current, string alias, HashSet used, out string? error) + { + error = null; + Guid desired = AuthoringSupport.DeterministicId(alias); + if (!used.Add(desired)) + { + error = "Duplicate alias '" + alias + "'; aliases must be unique within a model."; + return false; + } + + if (DiagramEditor.FindElement(diagram, current) is Entity connector) + { + DiagramElementHelper.SetCustomProperty(connector, AuthoringSupport.AliasPropertyName, alias); + } + + AuthoringSupport.RekeyConnector(diagram, current, desired); + return true; + } + + /// + /// Derives an identifier for an object the manifest did not give an alias, from a structural + /// key describing what the manifest does say about it. Two objects with the same key — two + /// unnamed stores, two flows between one pair of elements — get an occurrence suffix, so the + /// ids stay distinct and still land in the same order on the next apply. + /// + /// + /// This is a fallback, not a substitute for an alias: it is stable against re-applying the + /// same manifest, but renaming an object or reordering same-keyed siblings moves the id. + /// Declaring an alias is what makes an identity survive editing. + /// + private static Guid StructuralId(string key, HashSet used) + { + string unique = key; + for (int occurrence = 2; !used.Add(unique); occurrence++) + { + unique = key + "#" + occurrence.ToString(CultureInfo.InvariantCulture); + } + + return AuthoringSupport.DeterministicStructuralId(unique); + } + private static (int X, int Y) NextElementPosition(string? boundaryAlias, Dictionary boxes, ref int unassigned, int unassignedBaseY) { if (!string.IsNullOrEmpty(boundaryAlias) && boxes.TryGetValue(boundaryAlias!, out BoundaryBox? box)) diff --git a/src/ThreatModelForge.Formats/DeterministicGuid.cs b/src/ThreatModelForge.Formats/DeterministicGuid.cs index 727c60f..039c36f 100644 --- a/src/ThreatModelForge.Formats/DeterministicGuid.cs +++ b/src/ThreatModelForge.Formats/DeterministicGuid.cs @@ -28,6 +28,8 @@ public static class DeterministicGuid private const string PageNamespace = "tmforge-page:"; + private const string StructuralNamespace = "tmforge-structural:"; + /// /// Derives the identifier for an element or flow. This is the same derivation the authoring /// aliases use, so a model built from a manifest and the same model read back from JSON agree @@ -42,6 +44,21 @@ public static class DeterministicGuid /// A stable identifier for the id. public static Guid FromPageId(string id) => Derive(PageNamespace, id); + /// + /// Derives the identifier for an object that has no author-supplied id, from a key describing + /// its position in the document. + /// + /// + /// This is deliberately a different namespace from . Both are fed + /// author-influenced strings, so sharing one namespace would let an alias that happened to read + /// like a structural key derive the same identifier as the object that key describes — and + /// because the two are allocated against separate uniqueness sets, nothing would catch it. The + /// second object would simply overwrite the first and disappear from the model. + /// + /// The structural key. + /// A stable identifier for the key. + public static Guid FromStructuralKey(string key) => Derive(StructuralNamespace, key); + private static Guid Derive(string space, string id) { _ = id ?? throw new ArgumentNullException(nameof(id)); diff --git a/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs b/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs index 373db67..abff507 100644 --- a/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs +++ b/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs @@ -1,9 +1,13 @@ namespace ThreatModelForge.Cli.Tests { using System; + using System.Collections.Generic; using System.IO; + using System.Linq; using System.Text.Json; using Microsoft.VisualStudio.TestTools.UnitTesting; + using ThreatModelForge.Engine; + using ThreatModelForge.Model; /// /// Unit tests for the declarative manifest verbs (tmforge apply and tmforge export). @@ -20,6 +24,38 @@ public class ApplyExportTest "\"flows\":[{\"from\":\"EXT\",\"to\":\"P1\",\"name\":\"call\",\"props\":{\"Protocol\":\"HTTPS\"}}," + "{\"from\":\"P1\",\"to\":\"DS\",\"name\":\"write\"}]}"; + /// Two flows the structural key cannot tell apart: same endpoints, same name. + private const string DuplicateFlowManifest = + "{\"name\":\"T\",\"elements\":[" + + "{\"alias\":\"a\",\"kind\":\"external\",\"name\":\"Client\"}," + + "{\"alias\":\"b\",\"kind\":\"process\",\"name\":\"Service\"}]," + + "\"flows\":[{\"from\":\"a\",\"to\":\"b\",\"name\":\"Call\"}," + + "{\"from\":\"a\",\"to\":\"b\",\"name\":\"Call\"}]}"; + + /// A flow carrying its own alias. + private const string AliasedFlowManifest = + "{\"name\":\"T\",\"elements\":[" + + "{\"alias\":\"a\",\"kind\":\"external\",\"name\":\"Client\"}," + + "{\"alias\":\"b\",\"kind\":\"process\",\"name\":\"Service\"}]," + + "\"flows\":[{\"alias\":\"primary\",\"from\":\"a\",\"to\":\"b\",\"name\":\"Request\"}]}"; + + /// A manifest declaring no aliases at all — every id has to come from a structural key. + private const string NoAliasManifest = + "{\"name\":\"T\",\"boundaries\":[{\"name\":\"Edge\"}]," + + "\"elements\":[" + + "{\"kind\":\"external\",\"name\":\"Client\"}," + + "{\"kind\":\"process\",\"name\":\"Service\"}]," + + "\"flows\":[{\"from\":\"Client\",\"to\":\"Service\",\"name\":\"Call\"}]}"; + + /// + /// An alias written to look exactly like the structural key of another element. Both are + /// author-controlled strings, so the two derivations have to live in separate namespaces. + /// + private const string AliasShapedLikeAStructuralKeyManifest = + "{\"name\":\"T\",\"elements\":[" + + "{\"alias\":\"element:Widget\",\"kind\":\"process\",\"name\":\"Aliased\"}," + + "{\"kind\":\"process\",\"name\":\"Widget\"}]}"; + /// /// Gets or sets the working directory created for each test. /// @@ -156,6 +192,161 @@ public void ExportRoundTripsManifest() Assert.IsTrue(foundP1, "the exported manifest must preserve P1 with its boundary membership"); } + /// + /// Applying one manifest twice reproduces every identifier — the page, each component, and each + /// connector. + /// + /// + /// apply rebuilds the whole model, so any identifier it mints rather than derives moves + /// on every run. That is not cosmetic: a finding id is + /// {ruleId}:{diagram}:{target}:{occurrence}, so a fresh page guid alone moves every + /// finding in the model, orphans the triage recorded against a threat-register key, and makes + /// a no-op re-apply read as a wholesale rewrite in tmforge diff and in the pull-request + /// review the Action posts. + /// + [TestMethod] + public void ApplyingOneManifestTwiceReproducesEveryIdentifier() + { + Manifest manifest = ManifestSupport.Deserialize(SampleManifest) + ?? throw new InvalidOperationException("the sample manifest must parse"); + + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel first, out _, out string? firstError), firstError); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel second, out _, out string? secondError), secondError); + + DrawingSurfaceModel firstPage = first.DrawingSurfaceList[0]; + DrawingSurfaceModel secondPage = second.DrawingSurfaceList[0]; + + Assert.AreEqual(firstPage.Guid, secondPage.Guid, "the page identifier moved between applies"); + CollectionAssert.AreEquivalent( + firstPage.Borders.Keys.ToList(), + secondPage.Borders.Keys.ToList(), + "component identifiers moved between applies"); + CollectionAssert.AreEquivalent( + firstPage.Lines.Keys.ToList(), + secondPage.Lines.Keys.ToList(), + "connector identifiers moved between applies"); + } + + /// + /// Two flows between the same pair of elements with the same name still get distinct identifiers, + /// and the same two on the next apply. Deriving an id from a structural key is only safe if + /// same-keyed siblings are disambiguated; collapsing them would silently drop a flow. + /// + [TestMethod] + public void FlowsSharingEndpointsAndNameGetDistinctStableIdentifiers() + { + Manifest manifest = ManifestSupport.Deserialize(DuplicateFlowManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel first, out _, out string? error), error); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel second, out _, out error), error); + + List firstFlows = first.DrawingSurfaceList[0].Lines.Keys.ToList(); + + Assert.AreEqual(2, firstFlows.Count, "both flows must survive"); + Assert.AreEqual(2, firstFlows.Distinct().Count(), "the two flows collapsed onto one identifier"); + CollectionAssert.AreEquivalent(firstFlows, second.DrawingSurfaceList[0].Lines.Keys.ToList()); + } + + /// + /// A flow alias fixes the connector's identity and survives export, so a flow's identity + /// can be carried deliberately rather than inferred from its endpoints and name — which is what + /// lets a flow be renamed or re-pointed without moving its id. + /// + [TestMethod] + public void FlowAliasFixesIdentityAndSurvivesExport() + { + Manifest manifest = ManifestSupport.Deserialize(AliasedFlowManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + Guid expected = AuthoringSupport.DeterministicId("primary"); + Assert.IsTrue( + model.DrawingSurfaceList[0].Lines.ContainsKey(expected), + "the connector did not take the identifier its alias derives"); + + Manifest exported = ManifestSupport.Extract(model); + Assert.AreEqual("primary", exported.Flows![0].Alias, "export dropped the flow alias"); + } + + /// + /// Renaming an aliased flow leaves its identifier alone, which is the point of declaring one: + /// the structural fallback would move the id, because the name is part of its key. + /// + [TestMethod] + public void RenamingAnAliasedFlowKeepsItsIdentifier() + { + Manifest before = ManifestSupport.Deserialize(AliasedFlowManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Manifest after = ManifestSupport.Deserialize(AliasedFlowManifest.Replace("Request", "Renamed", StringComparison.Ordinal)) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsTrue(ManifestSupport.Build(before, force: false, out ThreatModel first, out _, out string? error), error); + Assert.IsTrue(ManifestSupport.Build(after, force: false, out ThreatModel second, out _, out error), error); + + CollectionAssert.AreEquivalent( + first.DrawingSurfaceList[0].Lines.Keys.ToList(), + second.DrawingSurfaceList[0].Lines.Keys.ToList(), + "renaming an aliased flow moved its identifier"); + } + + /// A flow may not take an alias an element already holds, or the two would collide. + [TestMethod] + public void FlowAliasCollidingWithAnElementIsRejected() + { + Manifest manifest = ManifestSupport.Deserialize( + AliasedFlowManifest.Replace("\"alias\":\"primary\"", "\"alias\":\"a\"", StringComparison.Ordinal)) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsFalse(ManifestSupport.Build(manifest, force: false, out _, out _, out string? error)); + StringAssert.Contains(error, "Duplicate alias"); + } + + /// + /// A manifest that declares no aliases is still reproduced identically. Aliases are optional and + /// a concise manifest is the documented starting point, so the structural fallback has to hold + /// on its own — otherwise the guarantee would reach only manifests somebody had already annotated. + /// + [TestMethod] + public void AManifestWithoutAliasesIsStillReproducedIdentically() + { + Manifest manifest = ManifestSupport.Deserialize(NoAliasManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel first, out _, out string? error), error); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel second, out _, out error), error); + + DrawingSurfaceModel firstPage = first.DrawingSurfaceList[0]; + DrawingSurfaceModel secondPage = second.DrawingSurfaceList[0]; + + Assert.AreEqual(3, firstPage.Borders.Count, "the boundary and both elements must be present"); + CollectionAssert.AreEquivalent( + firstPage.Borders.Keys.ToList(), + secondPage.Borders.Keys.ToList(), + "component identifiers moved between applies"); + CollectionAssert.AreEquivalent( + firstPage.Lines.Keys.ToList(), + secondPage.Lines.Keys.ToList(), + "connector identifiers moved between applies"); + } + + /// + /// An alias may read exactly like another element's structural key without the two colliding. + /// Both strings are author-controlled, and the alias and structural allocators use separate + /// uniqueness sets, so a shared derivation namespace would let the second object overwrite the + /// first in the diagram and vanish — while the summary still counted it. + /// + [TestMethod] + public void AnAliasShapedLikeAStructuralKeyDoesNotCollide() + { + Manifest manifest = ManifestSupport.Deserialize(AliasShapedLikeAStructuralKeyManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + Assert.AreEqual(2, model.DrawingSurfaceList[0].Borders.Count, "an element was lost to an identifier collision"); + } + private static JsonElement OpenData(string path) { (int exit, string stdout) = Capture(() => OpenCommand.Run(new[] { "--json", path })); From 02c17355d876a0b62d96f79f5d355a5e4e016fbd Mon Sep 17 00:00:00 2001 From: Hacks4Snacks Date: Wed, 29 Jul 2026 18:13:43 -0500 Subject: [PATCH 2/2] finalize flow auth identity --- docs/cli-reference.md | 106 +++- src/ThreatModelForge.Cli/ApplyCommand.cs | 15 +- src/ThreatModelForge.Cli/ExportCommand.cs | 8 +- src/ThreatModelForge.Cli/RemoveCommand.cs | 12 +- src/ThreatModelForge.Cli/SetCommand.cs | 2 +- .../Authoring/AuthoringSupport.cs | 54 +- .../Authoring/Manifest.cs | 23 + .../Authoring/ManifestBoundary.cs | 18 + .../Authoring/ManifestElement.cs | 18 + .../Authoring/ManifestPage.cs | 20 + .../Authoring/ManifestSupport.cs | 571 ++++++++++++++++-- .../ApplyExportTest.cs | 477 +++++++++++++++ 12 files changed, 1222 insertions(+), 102 deletions(-) create mode 100644 src/ThreatModelForge.Engine/Authoring/ManifestPage.cs diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7fbaf2b..328a0e9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -17,9 +17,11 @@ tmforge [options] - **Help.** `tmforge --help` lists commands; `tmforge --help` (also `-h`, `-?`) shows command-specific options. - **Version.** `tmforge --version` prints the released version. -- **Elements are addressed by GUID, alias, or unique name.** `add --alias ` gives an element a - stable, citeable id plus a handle that `connect`/`set`/`remove`/`rename`/`show` resolve (they also - accept a unique element name). Discover ids with `tmforge list` or the output of `add`. +- **Elements and flows are addressed by GUID, alias, or unique name.** `add --alias ` gives an + element a stable, citeable id plus a handle that `connect`/`set`/`remove`/`rename`/`show` resolve + (they also accept a unique name). A flow declared with an alias in a + [manifest](#declarative-manifest) is addressable the same way. Discover ids with `tmforge list` or + the output of `add`. ## Command summary @@ -431,11 +433,20 @@ tmforge connect payments.tm7 \ ### `remove` -Remove an element and its connected flows. The element is found on any page by default; `--page` -scopes the search to one page. +Remove an element and its connected flows, or remove a single flow. The object is found on any page +by default; `--page` scopes the search to one page. ```text -tmforge remove --id [--page ] [--json] +tmforge remove --id [--page ] [--json] +``` + +`--id` accepts a GUID, an **alias**, or a unique name. An alias resolves a flow as readily as an +element, so a flow given an alias in the manifest can be removed by that alias — and removing a flow +removes only that flow, leaving both endpoints in place. + +```bash +tmforge remove payments.tm7 --id ORD # the store, plus every flow touching it +tmforge remove payments.tm7 --id checkout-request # just that one flow ``` ### `rename` @@ -448,7 +459,7 @@ tmforge rename --id --name [--page ] [--json] ### `set` -Set the name and/or properties of an existing element or flow by GUID. Use this to resolve linter +Set the name and/or properties of an existing element or flow. Use this to resolve linter findings (e.g. add a missing `Protocol`) without recreating the element. List every settable property and its allowed values with [`tmforge properties`](#properties). @@ -456,9 +467,13 @@ and its allowed values with [`tmforge properties`](#properties). tmforge set --id [--name ] [--page ] [--property KEY=VALUE ...] [--json] ``` +`--id` accepts a GUID, an **alias**, or a unique name — and an alias or name resolves a **flow** just +as it resolves an element, so a flow given an alias in the manifest can be edited by that alias +instead of by its GUID. + ```bash tmforge set payments.tm7 --id 3333... --property Protocol=HTTPS --property Port=443 -tmforge set payments.tm7 --id 3333... --name "Authenticated request" --property AuthenticationScheme=OAuth +tmforge set payments.tm7 --id checkout-request --property AuthenticationScheme=OAuth ``` ### `page` @@ -891,6 +906,8 @@ source of truth for the `.tm7`. `apply` materializes it; `export` emits it from - `flows[].alias` does the same for a flow. It is optional: a flow without one still gets a stable id, derived from its endpoints and name. Declaring an alias is what lets a flow be **renamed or re-pointed without moving its id** — worth doing for any flow whose findings you expect to track. + It is also the handle you pass to `set --id` or `remove --id` to edit that flow later, instead of + looking up its GUID. - Either `kind` or `stencil` identifies an element; a stencil's base primitive sets the kind. **Every identifier `apply` assigns is derived, never minted**, so applying one manifest twice produces @@ -899,6 +916,72 @@ the same model: the same page, component, and connector ids. That is what keeps across rebuilds. Aliases are the durable form of that identity; the structural fallback is stable against re-applying a manifest, but renaming an alias-less object moves its id. +### Envelope + +A manifest may declare `"schema": "tmforge-manifest"` and a numeric `"version"`. Both are optional — +a manifest without them is read as the current version, because the concise form predates the +envelope. What the envelope buys is refusal rather than guesswork: a manifest from a newer build is +rejected with an upgrade hint instead of being read on assumptions that no longer hold, and a +document of another schema (a model, a rule pack) is named as such instead of being coerced into a +manifest that declares almost nothing. `export` stamps the envelope. + +### Pages + +A manifest may declare `pages`, and each boundary and element may name the one it is drawn on: + +```json +{ + "schema": "tmforge-manifest", + "version": 1, + "pages": [ + { "alias": "ctx", "name": "Context" }, + { "alias": "payments", "name": "Payments service" } + ], + "elements": [ + { "alias": "API", "kind": "process", "name": "Checkout API", "page": "ctx" }, + { "alias": "LEDGER", "kind": "process", "name": "Ledger", "page": "payments" } + ] +} +``` + +Pages are optional: a manifest that declares none builds onto one default page, exactly as before. An +object that names no page goes on the first. A page's alias (or its name) fixes its identifier, which +matters because finding ids embed it. + +**A flow may not cross pages.** A connector belongs to one surface, so both endpoints have to be +drawn on the same page; a flow whose endpoints are on different pages is refused rather than drawn on +whichever page resolved first. Naming a page the manifest does not declare is refused too, rather than +quietly placing the object on the first page. + +`export` records `pages` only when the model has more than one, so a single-page model produces +exactly the manifest it always did. + +### Geometry + +`boundaries[]` and `elements[]` accept optional `x`, `y`, `width`, and `height`. Position and size are +each all-or-nothing: supplying `x` without `y` is an error rather than a half-honoured request. + +- Omit them and the object is placed automatically, exactly as before. +- Supply them and the object goes where you asked. +- A boundary with no size grows to contain any member you placed explicitly, rather than clipping it. +- A boundary with a fixed size that cannot hold the members it must lay out is rejected, because the + automatic grid would put them outside the box you drew. + +A member placed outside the boundary it belongs to is allowed: nothing is being clipped, both objects +go where they were asked, and refusing it would make a diagram already in that state impossible to +re-apply. + +`tmforge export --geometry` records the geometry. It is off by default because the manifest's job is +to be reviewable, and coordinates churn whenever a diagram is tidied — they bury the property change +somebody actually needs to read. **Turn it on when the layout matters.** Without it, `export` followed +by `apply` re-runs automatic placement, and because trust-boundary containment is derived from +geometry that can change which boundaries a flow crosses: + +```bash +tmforge export --geometry --out payments.json payments.tm7 # lossless round trip +tmforge export --out payments.json payments.tm7 # concise, layout re-derived +``` + ### `apply` Build a model from a manifest. The whole model is built in memory and written **atomically**, so a @@ -922,15 +1005,16 @@ tmforge apply model.json --dry-run ### `export` -Emit a manifest from an existing model (round-trips with `apply`). Geometry is intentionally dropped -so the manifest stays a stable, diffable source. +Emit a manifest from an existing model (round-trips with `apply`). Geometry is dropped unless you ask +for it with `--geometry`, so the manifest stays a stable, diffable source by default. ```text -tmforge export [--out ] [--json] +tmforge export [--out ] [--geometry] [--json] ``` ```bash tmforge export payments.tm7 --out payments.json +tmforge export --geometry payments.tm7 --out payments.json tmforge export payments.tm7 | jq '.elements' ``` diff --git a/src/ThreatModelForge.Cli/ApplyCommand.cs b/src/ThreatModelForge.Cli/ApplyCommand.cs index 46b9c3d..30088df 100644 --- a/src/ThreatModelForge.Cli/ApplyCommand.cs +++ b/src/ThreatModelForge.Cli/ApplyCommand.cs @@ -55,20 +55,9 @@ public static int Run(string[] args) return 1; } - Manifest? manifest; - try + if (!ManifestSupport.TryRead(File.ReadAllText(input!), out Manifest? manifest, out string? readError)) { - manifest = ManifestSupport.Deserialize(File.ReadAllText(input!)); - } - catch (JsonException ex) - { - Console.Error.WriteLine("Invalid manifest JSON: " + ex.Message); - return 1; - } - - if (manifest == null) - { - Console.Error.WriteLine("The manifest is empty."); + Console.Error.WriteLine(readError); return 1; } diff --git a/src/ThreatModelForge.Cli/ExportCommand.cs b/src/ThreatModelForge.Cli/ExportCommand.cs index 3842bdd..cd5c853 100644 --- a/src/ThreatModelForge.Cli/ExportCommand.cs +++ b/src/ThreatModelForge.Cli/ExportCommand.cs @@ -25,7 +25,7 @@ public static int Run(string[] args) return 1; } - CliArgs parsed = CliArgs.Parse(args, new[] { "out" }); + CliArgs parsed = CliArgs.Parse(args, new[] { "out" }, new[] { "geometry" }); if (parsed.Help) { PrintUsage(); @@ -53,7 +53,7 @@ public static int Run(string[] args) } (ThreatModel model, _) = CliModelLoader.Load(input!); - Manifest manifest = ManifestSupport.Extract(model); + Manifest manifest = ManifestSupport.Extract(model, parsed.HasFlag("geometry")); string json = ManifestSupport.Serialize(manifest); string? output = parsed.Get("out"); @@ -90,9 +90,11 @@ private static void PrintUsage() { Console.Error.WriteLine("Export a model as a declarative JSON manifest (round-trips with 'tmforge apply')."); Console.Error.WriteLine("Usage:"); - Console.Error.WriteLine(" tmforge export [--out ] [--json] "); + Console.Error.WriteLine(" tmforge export [--out ] [--geometry] [--json] "); Console.Error.WriteLine(); Console.Error.WriteLine("If --out is omitted, the manifest is written to standard output."); + Console.Error.WriteLine("--geometry records each object's position and size, so a hand-laid-out diagram"); + Console.Error.WriteLine("survives a round trip. It is off by default to keep the manifest diffable."); } } } diff --git a/src/ThreatModelForge.Cli/RemoveCommand.cs b/src/ThreatModelForge.Cli/RemoveCommand.cs index 7df0788..f795dd9 100644 --- a/src/ThreatModelForge.Cli/RemoveCommand.cs +++ b/src/ThreatModelForge.Cli/RemoveCommand.cs @@ -8,8 +8,8 @@ namespace ThreatModelForge.Cli using ThreatModelForge.Model; /// - /// Implements the tmforge remove command: removes an element by GUID. Removing a - /// component also removes any data flows attached to it. + /// Implements the tmforge remove command: removes an element or a flow. Removing an + /// element also removes any data flows attached to it; removing a flow removes only that flow. /// internal static class RemoveCommand { @@ -83,7 +83,7 @@ public static int Run(string[] args) } else { - Console.Error.WriteLine("Removed " + removed.Count + " element(s) from " + input + "."); + Console.Error.WriteLine("Removed " + removed.Count + " object(s) from " + input + "."); } return 0; @@ -91,12 +91,12 @@ public static int Run(string[] args) private static void PrintUsage() { - Console.Error.WriteLine("Remove an element (and its connected flows)."); + Console.Error.WriteLine("Remove an element (and its connected flows), or remove a single flow."); Console.Error.WriteLine("Usage:"); Console.Error.WriteLine(" tmforge remove --id [--page ] [--json] "); Console.Error.WriteLine(); - Console.Error.WriteLine("--id accepts a GUID, an element --alias, or a unique element name."); - Console.Error.WriteLine("The element is found on any page by default; --page scopes the search to one page."); + Console.Error.WriteLine("--id accepts a GUID, an element or flow --alias, or a unique name."); + Console.Error.WriteLine("The object is found on any page by default; --page scopes the search to one page."); } } } diff --git a/src/ThreatModelForge.Cli/SetCommand.cs b/src/ThreatModelForge.Cli/SetCommand.cs index 135ee1c..7ddefd9 100644 --- a/src/ThreatModelForge.Cli/SetCommand.cs +++ b/src/ThreatModelForge.Cli/SetCommand.cs @@ -134,7 +134,7 @@ private static void PrintUsage() Console.Error.WriteLine("Usage:"); Console.Error.WriteLine(" tmforge set --id [--name ] [--page ] [--property KEY=VALUE]... [--json] "); Console.Error.WriteLine(); - Console.Error.WriteLine("--id accepts a GUID, an element --alias, or a unique element name."); + Console.Error.WriteLine("--id accepts a GUID, an element or flow --alias, or a unique name."); Console.Error.WriteLine(); Console.Error.WriteLine("Resolve analysis findings, e.g. --property Protocol=HTTPS --property Port=443,"); Console.Error.WriteLine("--property DataType=\"Customer Content\", or --property AuthenticationScheme=OAuth."); diff --git a/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs b/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs index 18ac103..f80db45 100644 --- a/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs +++ b/src/ThreatModelForge.Engine/Authoring/AuthoringSupport.cs @@ -196,7 +196,7 @@ public static bool TryResolveElementId( if (aliasMatches.Count > 1) { error = "Alias '" + token + "' matches " + aliasMatches.Count.ToString(CultureInfo.InvariantCulture) + - " elements; use a GUID to disambiguate."; + " objects; use a GUID to disambiguate."; return false; } @@ -209,11 +209,12 @@ public static bool TryResolveElementId( if (nameMatches.Count > 1) { error = "Name '" + token + "' matches " + nameMatches.Count.ToString(CultureInfo.InvariantCulture) + - " elements; use an --alias or a GUID."; + " objects; use an --alias or a GUID."; return false; } - error = "No element with GUID, alias, or name '" + token + "' (run 'tmforge list components ')."; + error = "No element or flow with GUID, alias, or name '" + token + + "' (run 'tmforge list components ' or 'tmforge list flows ')."; return false; } @@ -248,23 +249,37 @@ public static bool TryResolveElementId( /// The diagram containing the component. /// The component's current GUID. /// The desired GUID. + /// + /// The diagram does not hold . Every caller has just created it there, + /// so this means the wrong surface was passed — and returning quietly would leave the object + /// carrying the minted identifier this re-key exists to replace. + /// public static void RekeyComponent(DrawingSurfaceModel diagram, Guid current, Guid desired) { + if (diagram == null) + { + throw new ArgumentNullException(nameof(diagram)); + } + if (current == desired) { return; } - if (diagram.Borders.TryGetValue(current, out object? border)) + if (!diagram.Borders.TryGetValue(current, out object? border)) { - diagram.Borders.Remove(current); - if (border is Entity entity) - { - entity.Guid = desired; - } + throw new ArgumentException( + "The diagram does not contain a component with id " + current.ToString() + ".", + nameof(current)); + } - diagram.Borders[desired] = border!; + diagram.Borders.Remove(current); + if (border is Entity entity) + { + entity.Guid = desired; } + + diagram.Borders[desired] = border!; } /// @@ -274,6 +289,7 @@ public static void RekeyComponent(DrawingSurfaceModel diagram, Guid current, Gui /// The diagram containing the connector. /// The connector's current GUID. /// The desired GUID. + /// The diagram does not hold . public static void RekeyConnector(DrawingSurfaceModel diagram, Guid current, Guid desired) { if (diagram == null) @@ -286,16 +302,20 @@ public static void RekeyConnector(DrawingSurfaceModel diagram, Guid current, Gui return; } - if (diagram.Lines.TryGetValue(current, out object? line)) + if (!diagram.Lines.TryGetValue(current, out object? line)) { - diagram.Lines.Remove(current); - if (line is Connector connector) - { - connector.Guid = desired; - } + throw new ArgumentException( + "The diagram does not contain a connector with id " + current.ToString() + ".", + nameof(current)); + } - diagram.Lines[desired] = line!; + diagram.Lines.Remove(current); + if (line is Connector connector) + { + connector.Guid = desired; } + + diagram.Lines[desired] = line!; } /// diff --git a/src/ThreatModelForge.Engine/Authoring/Manifest.cs b/src/ThreatModelForge.Engine/Authoring/Manifest.cs index 3761bf6..43ec782 100644 --- a/src/ThreatModelForge.Engine/Authoring/Manifest.cs +++ b/src/ThreatModelForge.Engine/Authoring/Manifest.cs @@ -10,9 +10,32 @@ namespace ThreatModelForge.Engine /// public sealed class Manifest { + /// The schema name a versioned manifest declares. + public const string SchemaName = "tmforge-manifest"; + + /// The manifest schema version this build writes and can read. + public const int CurrentVersion = 1; + + /// + /// Gets or sets the schema name. Absent on manifests written before the envelope existed, which + /// are read as . + /// + public string? Schema { get; set; } + + /// + /// Gets or sets the schema version. Absent on manifests written before the envelope existed. + /// + public int? Version { get; set; } + /// Gets or sets the model title. public string? Name { get; set; } + /// + /// Gets or sets the pages the model is drawn on. Optional: a manifest that declares none builds + /// onto one default page. + /// + public List? Pages { get; set; } + /// Gets or sets the trust boundaries. public List? Boundaries { get; set; } diff --git a/src/ThreatModelForge.Engine/Authoring/ManifestBoundary.cs b/src/ThreatModelForge.Engine/Authoring/ManifestBoundary.cs index ee0cda1..294c0a6 100644 --- a/src/ThreatModelForge.Engine/Authoring/ManifestBoundary.cs +++ b/src/ThreatModelForge.Engine/Authoring/ManifestBoundary.cs @@ -8,5 +8,23 @@ public sealed class ManifestBoundary /// Gets or sets the boundary's display name. public string? Name { get; set; } + + /// Gets or sets the alias of the page this boundary is drawn on. Defaults to the first page. + public string? Page { get; set; } + + /// + /// Gets or sets the left edge. Optional: a boundary with no geometry is laid out automatically + /// and sized to hold its members. Supplying it fixes the boundary in place. + /// + public int? X { get; set; } + + /// Gets or sets the top edge. See . + public int? Y { get; set; } + + /// Gets or sets the width. See . + public int? Width { get; set; } + + /// Gets or sets the height. See . + public int? Height { get; set; } } } diff --git a/src/ThreatModelForge.Engine/Authoring/ManifestElement.cs b/src/ThreatModelForge.Engine/Authoring/ManifestElement.cs index 7442e60..3050f60 100644 --- a/src/ThreatModelForge.Engine/Authoring/ManifestElement.cs +++ b/src/ThreatModelForge.Engine/Authoring/ManifestElement.cs @@ -20,7 +20,25 @@ public sealed class ManifestElement /// Gets or sets the alias of the trust boundary this element belongs to. public string? Boundary { get; set; } + /// Gets or sets the alias of the page this element is drawn on. Defaults to the first page. + public string? Page { get; set; } + /// Gets or sets the typed custom properties the analyzer reads. public Dictionary? Props { get; set; } + + /// + /// Gets or sets the left edge. Optional: an element with no geometry is placed automatically + /// inside its boundary. Supplying it fixes the element in place. + /// + public int? X { get; set; } + + /// Gets or sets the top edge. See . + public int? Y { get; set; } + + /// Gets or sets the width. See . + public int? Width { get; set; } + + /// Gets or sets the height. See . + public int? Height { get; set; } } } diff --git a/src/ThreatModelForge.Engine/Authoring/ManifestPage.cs b/src/ThreatModelForge.Engine/Authoring/ManifestPage.cs new file mode 100644 index 0000000..c733353 --- /dev/null +++ b/src/ThreatModelForge.Engine/Authoring/ManifestPage.cs @@ -0,0 +1,20 @@ +namespace ThreatModelForge.Engine +{ + /// A page (diagram) in a . + /// + /// Pages are optional. A manifest that declares none builds onto a single default page, which is + /// what every manifest written before pages existed expects. Declaring them lets one manifest + /// describe a context diagram plus per-service diagrams, the way a .tm7 already can. + /// + public sealed class ManifestPage + { + /// + /// Gets or sets the stable alias elements and boundaries use to say which page they are on. + /// It also fixes the page's identifier, which finding ids embed. + /// + public string? Alias { get; set; } + + /// Gets or sets the page's display name. + public string? Name { get; set; } + } +} diff --git a/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs b/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs index 2990c02..462c6f6 100644 --- a/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs +++ b/src/ThreatModelForge.Engine/Authoring/ManifestSupport.cs @@ -2,6 +2,7 @@ namespace ThreatModelForge.Engine { using System; using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Text.Json; @@ -38,6 +39,51 @@ public static class ManifestSupport return JsonSerializer.Deserialize(json, SerializerOptions); } + /// + /// Reads a manifest and checks this build can interpret it. A manifest that declares no + /// envelope is read as the current version: the shape predates the envelope and is still the + /// documented concise form, so requiring one would break every manifest already written. + /// + /// The manifest JSON. + /// On success, the parsed manifest. + /// On failure, why the manifest was refused. + /// when the manifest was read. + public static bool TryRead(string json, [NotNullWhen(true)] out Manifest? manifest, out string? error) + { + manifest = null; + error = null; + + try + { + // Checked against the raw JSON rather than the deserialized object, because the DTO + // supplies defaults: a file declaring a foreign schema would still deserialize into + // something that looks like a valid manifest. This is the same trap the analysis + // document reader documents. + using (JsonDocument probe = JsonDocument.Parse(json)) + { + if (!CheckEnvelope(probe.RootElement, out error)) + { + return false; + } + } + + manifest = JsonSerializer.Deserialize(json, SerializerOptions); + } + catch (JsonException ex) + { + error = "The manifest is not valid JSON: " + ex.Message; + return false; + } + + if (manifest == null) + { + error = "The manifest is empty."; + return false; + } + + return true; + } + /// Serializes a manifest to indented, camelCase JSON. /// The manifest to serialize. /// The JSON text. @@ -67,13 +113,40 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o // rebuilds the whole model, so a fresh guid anywhere means two applies of one manifest // share no identity: finding ids (which embed the page and the target), threat-register // keys, and structural diffs all move even though nothing about the model changed. - DrawingSurfaceModel diagram = new DrawingSurfaceModel + List pages = manifest.Pages is { Count: > 0 } + ? manifest.Pages + : new List { new ManifestPage { Name = DefaultPageName } }; + + Dictionary pagesByRef = new Dictionary(StringComparer.OrdinalIgnoreCase); + List pageOrder = new List(pages.Count); + foreach (ManifestPage page in pages) { - Guid = AuthoringSupport.DeterministicPageId(DefaultPageName), - Header = DefaultPageName, - }; + string header = page.Name ?? page.Alias ?? DefaultPageName; + string key = page.Alias ?? header; + DrawingSurfaceModel surface = new DrawingSurfaceModel + { + Guid = AuthoringSupport.DeterministicPageId(key), + Header = header, + }; + + PageContext context = new PageContext(surface); + if (pagesByRef.ContainsKey(key)) + { + error = "Duplicate page '" + key + "'; pages must be uniquely referenceable."; + return false; + } + + pagesByRef[key] = context; + if (!string.IsNullOrEmpty(page.Alias) && !pagesByRef.ContainsKey(header)) + { + // Reachable by name too, so an element can say either. + pagesByRef[header] = context; + } + + pageOrder.Add(context); + model.DrawingSurfaceList.Add(surface); + } - model.DrawingSurfaceList.Add(diagram); if (!string.IsNullOrWhiteSpace(manifest.Name)) { model.MetaInformation ??= new MetaInformation(); @@ -88,27 +161,96 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o HashSet aliasIds = new HashSet(); HashSet structuralKeys = new HashSet(StringComparer.Ordinal); + // Only auto-placed members are counted: an explicitly placed one does not consume a grid + // cell, so counting it would inflate every boundary that holds one. Dictionary memberCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (ManifestElement element in elements.Where(element => !string.IsNullOrEmpty(element.Boundary))) + foreach (ManifestElement element in elements.Where(element => !string.IsNullOrEmpty(element.Boundary) && !element.X.HasValue)) { memberCounts.TryGetValue(element.Boundary!, out int count); memberCounts[element.Boundary!] = count + 1; } + // Kinds are resolved before anything is created because a boundary has to be sized before + // its members exist, and an explicitly placed member's size depends on its kind. + List resolved = new List(elements.Count); + foreach (ManifestElement element in elements) + { + if (!ValidateGeometry(element.X, element.Y, element.Width, element.Height, Describe(element), out error) || + !TryResolveKind(element, out StencilKind kind, out StencilDto? stencil, out error)) + { + return false; + } + + resolved.Add(new ResolvedElement(element, kind, stencil)); + } + + // An explicitly placed member constrains the boundary holding it: an auto-sized boundary + // grows to contain it rather than clipping it, and an explicitly sized one is checked + // against it below. Clipping would silently move the member out of the boundary, which + // changes which trust boundaries its flows cross — an analysis change, not a drawing one. + Dictionary memberExtents = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ResolvedElement entry in resolved) + { + if (string.IsNullOrEmpty(entry.Element.Boundary) || !entry.Element.X.HasValue) + { + continue; + } + + Rect rect = entry.PlacedBounds(); + memberExtents[entry.Element.Boundary!] = memberExtents.TryGetValue(entry.Element.Boundary!, out Rect seen) + ? Rect.Union(seen, rect) + : rect; + } + Dictionary boxes = new Dictionary(StringComparer.OrdinalIgnoreCase); - int cursorY = 40; foreach (ManifestBoundary boundary in boundaries) { + if (!ValidateGeometry(boundary.X, boundary.Y, boundary.Width, boundary.Height, Describe(boundary), out error) || + !TryResolvePage(boundary.Page, pagesByRef, pageOrder, Describe(boundary), out PageContext page, out error)) + { + return false; + } + + DrawingSurfaceModel surface = page.Surface; int members = !string.IsNullOrEmpty(boundary.Alias) && memberCounts.TryGetValue(boundary.Alias!, out int m) ? m : 0; int rows = Math.Max(1, (members + 2) / 3); - int width = 24 + (3 * 120) + 24; - int height = 48 + (rows * 84) + 24; - int x = 40; - int y = cursorY; - - Guid id = editor.AddElement(diagram, StencilKind.TrustBoundary, x, y); - editor.ResizeElement(diagram, id, x, y, width, height); - Entity? boundaryEntity = DiagramEditor.FindElement(diagram, id); + int columns = Math.Min(Math.Max(members, 1), 3); + int gridWidth = 24 + (columns * 120) + 24; + int gridHeight = 48 + (rows * 84) + 24; + Rect bounds = new Rect( + boundary.X ?? 40, + boundary.Y ?? page.CursorY, + boundary.Width ?? (24 + (3 * 120) + 24), + boundary.Height ?? gridHeight); + + bool sizeWasDeclared = boundary.Width.HasValue; + + // A fixed boundary that cannot hold the members it has to lay out is the one case where + // honouring the manifest is impossible: the grid would place them outside the box the + // author drew. Explicitly placed members are not checked — nothing is being clipped + // there, both objects go exactly where they were asked to, and a member sitting outside + // a boundary it claims membership of is a modelling question rather than a layout + // failure. Refusing it would make an exported diagram impossible to re-apply. + if (sizeWasDeclared && members > 0 && (bounds.Width < gridWidth || bounds.Height < gridHeight)) + { + error = Describe(boundary) + " is fixed at " + bounds + " but has to lay out " + + members.ToString(CultureInfo.InvariantCulture) + " member(s), which need at least " + + gridWidth.ToString(CultureInfo.InvariantCulture) + "x" + + gridHeight.ToString(CultureInfo.InvariantCulture) + + ". Enlarge it, place those members explicitly, or drop its size to have it fit them."; + return false; + } + + if (!sizeWasDeclared && + !string.IsNullOrEmpty(boundary.Alias) && + memberExtents.TryGetValue(boundary.Alias!, out Rect extent)) + { + bounds = Rect.Union(bounds, Grow(extent, 24)); + } + + Guid id = editor.AddElement(surface, StencilKind.TrustBoundary, bounds.X, bounds.Y); + editor.ResizeElement(surface, id, bounds.X, bounds.Y, bounds.Width, bounds.Height); + Entity? boundaryEntity = DiagramEditor.FindElement(surface, id); if (boundaryEntity != null && !string.IsNullOrWhiteSpace(boundary.Name)) { DiagramElementHelper.SetName(boundaryEntity, boundary.Name!); @@ -116,38 +258,50 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o if (!string.IsNullOrEmpty(boundary.Alias)) { - if (!TryAssignAlias(diagram, id, boundary.Alias!, aliasIds, out _, out error)) + if (!TryAssignAlias(surface, id, boundary.Alias!, aliasIds, out _, out error)) { return false; } - boxes[boundary.Alias!] = new BoundaryBox(x, y); + boxes[boundary.Alias!] = new BoundaryBox(bounds, sizeWasDeclared); } else { - AuthoringSupport.RekeyComponent(diagram, id, StructuralId("boundary:" + boundary.Name, structuralKeys)); + AuthoringSupport.RekeyComponent(surface, id, StructuralId("boundary:" + boundary.Name, structuralKeys)); } - cursorY = y + height + 40; + page.CursorY = bounds.Bottom + 40; } - int unassigned = 0; - foreach (ManifestElement element in elements) + foreach (ResolvedElement entry in resolved) { - if (!TryResolveKind(element, out StencilKind kind, out StencilDto? stencil, out error)) + ManifestElement element = entry.Element; + StencilKind kind = entry.Kind; + StencilDto? stencil = entry.Stencil; + if (!TryResolvePage(element.Page, pagesByRef, pageOrder, Describe(element), out PageContext page, out error)) { return false; } - (int ex, int ey) = NextElementPosition(element.Boundary, boxes, ref unassigned, cursorY); - Guid id = editor.AddElement(diagram, kind, ex, ey); - Entity? added = DiagramEditor.FindElement(diagram, id); + DrawingSurfaceModel surface = page.Surface; + int unassigned = page.Unassigned; + (int ex, int ey) = element.X.HasValue + ? (element.X.Value, element.Y!.Value) + : NextElementPosition(element.Boundary, boxes, ref unassigned, page.CursorY); + page.Unassigned = unassigned; + Guid id = editor.AddElement(surface, kind, ex, ey); + Entity? added = DiagramEditor.FindElement(surface, id); if (added == null) { error = "Failed to create element."; return false; } + if (element.Width.HasValue) + { + editor.ResizeElement(surface, id, ex, ey, element.Width.Value, element.Height!.Value); + } + string? name = element.Name ?? stencil?.Label; if (!string.IsNullOrEmpty(name)) { @@ -174,14 +328,14 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o return false; } - if (!string.IsNullOrEmpty(element.Alias) && !TryAssignAlias(diagram, id, element.Alias!, aliasIds, out _, out error)) + if (!string.IsNullOrEmpty(element.Alias) && !TryAssignAlias(surface, id, element.Alias!, aliasIds, out _, out error)) { return false; } if (string.IsNullOrEmpty(element.Alias)) { - AuthoringSupport.RekeyComponent(diagram, id, StructuralId("element:" + name, structuralKeys)); + AuthoringSupport.RekeyComponent(surface, id, StructuralId("element:" + name, structuralKeys)); } } @@ -193,26 +347,37 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o return false; } - if (!AuthoringSupport.TryResolveElementId(model, diagram, flow.From!, out Guid source, out error) || - !AuthoringSupport.TryResolveElementId(model, diagram, flow.To!, out Guid target, out error)) + // A flow lives on one page, so both endpoints must be found on the same one. Resolving + // against the whole model and drawing the connector on whichever page came first would + // produce a connector whose endpoints are not on its own surface. + PageContext? host = null; + Guid source = Guid.Empty; + Guid target = Guid.Empty; + foreach (PageContext candidate in pageOrder) { - return false; - } - - if (!diagram.Borders.ContainsKey(source)) - { - error = "Flow source is not a component: " + flow.From; - return false; + if (AuthoringSupport.TryResolveElementId(model, candidate.Surface, flow.From!, out Guid from, out _) && + candidate.Surface.Borders.ContainsKey(from) && + AuthoringSupport.TryResolveElementId(model, candidate.Surface, flow.To!, out Guid to, out _) && + candidate.Surface.Borders.ContainsKey(to)) + { + host = candidate; + source = from; + target = to; + break; + } } - if (!diagram.Borders.ContainsKey(target)) + if (host == null) { - error = "Flow target is not a component: " + flow.To; + error = "Flow '" + (flow.Alias ?? flow.Name ?? flow.From + " -> " + flow.To) + + "' does not have both endpoints on one page. A flow cannot cross pages: '" + flow.From + + "' and '" + flow.To + "' must be drawn on the same one."; return false; } - Guid id = editor.AddConnector(diagram, source, target); - Entity? connector = DiagramEditor.FindElement(diagram, id); + DrawingSurfaceModel surface = host.Surface; + Guid id = editor.AddConnector(surface, source, target); + Entity? connector = DiagramEditor.FindElement(surface, id); if (connector != null && !string.IsNullOrEmpty(flow.Name)) { DiagramElementHelper.SetName(connector, flow.Name!); @@ -226,7 +391,7 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o if (!string.IsNullOrEmpty(flow.Alias)) { - if (!TryAssignFlowAlias(diagram, id, flow.Alias!, aliasIds, out error)) + if (!TryAssignFlowAlias(surface, id, flow.Alias!, aliasIds, out error)) { return false; } @@ -234,7 +399,7 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o else { string key = "flow:" + flow.From + ">" + flow.To + ":" + flow.Name; - AuthoringSupport.RekeyConnector(diagram, id, StructuralId(key, structuralKeys)); + AuthoringSupport.RekeyConnector(surface, id, StructuralId(key, structuralKeys)); } } @@ -243,12 +408,14 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o // missing from the model while the summary below still counted it. Checking what was built // against what was declared turns that into a failure instead of silent loss. int expectedComponents = boundaries.Count + elements.Count; - if (diagram.Borders.Count != expectedComponents || diagram.Lines.Count != flows.Count) + int builtComponents = pageOrder.Sum(page => page.Surface.Borders.Count); + int builtConnectors = pageOrder.Sum(page => page.Surface.Lines.Count); + if (builtComponents != expectedComponents || builtConnectors != flows.Count) { error = "Internal error: the manifest declared " + expectedComponents.ToString(CultureInfo.InvariantCulture) + " components and " + flows.Count.ToString(CultureInfo.InvariantCulture) + " flows, but the model holds " + - diagram.Borders.Count.ToString(CultureInfo.InvariantCulture) + " and " + - diagram.Lines.Count.ToString(CultureInfo.InvariantCulture) + ". Two objects resolved to one identifier."; + builtComponents.ToString(CultureInfo.InvariantCulture) + " and " + + builtConnectors.ToString(CultureInfo.InvariantCulture) + ". Two objects resolved to one identifier."; return false; } @@ -258,19 +425,55 @@ public static bool Build(Manifest manifest, bool force, out ThreatModel model, o /// /// Extracts a manifest from a model: boundaries, elements, and flows across every page, with - /// each flow endpoint referenced by the element's alias (or name). Geometry is intentionally - /// dropped so the manifest is a stable, review-friendly authoring source. + /// each flow endpoint referenced by the element's alias (or name). Geometry is dropped, so the + /// manifest stays a stable, review-friendly authoring source — see the overload for what that + /// costs. + /// + /// The model to capture. + /// The extracted manifest. + public static Manifest Extract(ThreatModel model) => Extract(model, includeGeometry: false); + + /// + /// Extracts a manifest, optionally capturing where each object sits. /// + /// + /// + /// Geometry is off by default because the manifest's job is to be a reviewable source: layout + /// churns whenever a diagram is tidied, and coordinates in the diff bury the property change + /// somebody actually needs to read. Turn it on to round-trip a diagram that was laid out by + /// hand, which is the case where dropping it silently loses work. + /// + /// + /// That default is a deliberate trade, not a free one. Trust-boundary containment is derived + /// from geometry, so re-applying a manifest extracted without it re-runs automatic placement + /// and can change which boundaries a flow crosses. Measured on the sample model, a concise + /// round trip moved three flows across boundaries while a round trip with geometry reported no + /// differences at all. Prefer the geometry form whenever the result will be analysed rather + /// than only read. + /// + /// /// The model to capture. + /// Whether to record positions and sizes. /// The extracted manifest. - public static Manifest Extract(ThreatModel model) + public static Manifest Extract(ThreatModel model, bool includeGeometry) { List boundaries = new List(); List elements = new List(); List flows = new List(); + // Pages are recorded only when there is more than one. A single-page model exports exactly + // the shape it always has, so nothing that reads an existing manifest sees a new field. + bool multiPage = model.DrawingSurfaceList.Count > 1; + List pages = new List(); + foreach (DrawingSurfaceModel surface in model.DrawingSurfaceList) { + string? pageRef = multiPage ? NullIfEmpty(surface.Header ?? string.Empty) : null; + if (multiPage) + { + pages.Add(new ManifestPage { Name = pageRef }); + } + foreach (Entity entity in surface.Borders.Values.OfType()) { IReadOnlyDictionary props = DiagramElementHelper.GetCustomProperties(entity); @@ -278,11 +481,22 @@ public static Manifest Extract(ThreatModel model) string name = DiagramElementHelper.GetName(entity); if (entity is BorderBoundary) { - boundaries.Add(new ManifestBoundary { Alias = alias, Name = NullIfEmpty(name) }); + (int? bx, int? by, int? bw, int? bh) = GeometryOf(entity, includeGeometry); + boundaries.Add(new ManifestBoundary + { + Alias = alias, + Name = NullIfEmpty(name), + Page = pageRef, + X = bx, + Y = by, + Width = bw, + Height = bh, + }); continue; } Dictionary userProps = FilterProps(props); + (int? ex, int? ey, int? ew, int? eh) = GeometryOf(entity, includeGeometry); elements.Add(new ManifestElement { Alias = alias, @@ -290,7 +504,12 @@ public static Manifest Extract(ThreatModel model) Name = NullIfEmpty(name), Stencil = props.TryGetValue("StencilType", out string? stencil) ? stencil : null, Boundary = props.TryGetValue(AuthoringSupport.BoundaryPropertyName, out string? boundary) ? boundary : null, + Page = pageRef, Props = userProps.Count > 0 ? userProps : null, + X = ex, + Y = ey, + Width = ew, + Height = eh, }); } @@ -311,13 +530,169 @@ public static Manifest Extract(ThreatModel model) return new Manifest { + Schema = Manifest.SchemaName, + Version = Manifest.CurrentVersion, Name = NullIfEmpty(model.MetaInformation?.ThreatModelName ?? string.Empty), + Pages = pages.Count > 0 ? pages : null, Boundaries = boundaries.Count > 0 ? boundaries : null, Elements = elements.Count > 0 ? elements : null, Flows = flows.Count > 0 ? flows : null, }; } + /// + /// Checks the envelope against the raw JSON. A manifest with no schema is the concise + /// form that predates the envelope and is read as the current version; one that names a + /// different schema is refused rather than coerced, because a model or a rule pack would + /// otherwise deserialize into an empty-looking manifest and apply as an empty model. + /// + private static bool CheckEnvelope(JsonElement root, out string? error) + { + error = null; + if (root.ValueKind != JsonValueKind.Object) + { + error = "The manifest must be a JSON object."; + return false; + } + + if (!TryFindProperty(root, "schema", out JsonElement schema)) + { + return true; + } + + string declared = schema.ValueKind == JsonValueKind.String + ? schema.GetString() ?? string.Empty + : schema.ToString(); + if (!string.Equals(declared, Manifest.SchemaName, StringComparison.Ordinal)) + { + error = "Expected a '" + Manifest.SchemaName + "' document but found '" + declared + + "'. Check that this is an authoring manifest rather than a model or a rule pack."; + return false; + } + + if (!TryFindProperty(root, "version", out JsonElement version)) + { + return true; + } + + if (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out int declaredVersion)) + { + error = "The manifest declares a non-numeric schema version."; + return false; + } + + if (declaredVersion > Manifest.CurrentVersion) + { + error = "The manifest declares schema version " + + declaredVersion.ToString(CultureInfo.InvariantCulture) + + ", which this build cannot read (it understands version " + + Manifest.CurrentVersion.ToString(CultureInfo.InvariantCulture) + "). Upgrade tmforge."; + return false; + } + + if (declaredVersion < 1) + { + error = "The manifest declares an invalid schema version: " + + declaredVersion.ToString(CultureInfo.InvariantCulture) + "."; + return false; + } + + return true; + } + + /// Finds a top-level property by name, matching the reader's case-insensitivity. + private static bool TryFindProperty(JsonElement root, string name, out JsonElement value) + { + foreach (JsonProperty property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } + + /// + /// Resolves the page an object names, defaulting to the first declared page so a manifest that + /// never mentions pages behaves exactly as it did before pages existed. + /// + private static bool TryResolvePage( + string? reference, + Dictionary pagesByRef, + List pageOrder, + string what, + out PageContext page, + out string? error) + { + error = null; + if (string.IsNullOrEmpty(reference)) + { + page = pageOrder[0]; + return true; + } + + if (pagesByRef.TryGetValue(reference!, out PageContext? found)) + { + page = found; + return true; + } + + page = pageOrder[0]; + error = what + " names page '" + reference + "', which the manifest does not declare."; + return false; + } + + /// + /// Rejects half-supplied geometry. A position or a size is only meaningful as a pair, and + /// silently defaulting the missing half would place the object somewhere the author did not + /// ask for while looking like it had been honoured. + /// + private static bool ValidateGeometry(int? x, int? y, int? width, int? height, string what, out string? error) + { + error = null; + if (x.HasValue != y.HasValue) + { + error = what + " supplies only one of 'x' and 'y'. Give both or neither."; + return false; + } + + if (width.HasValue != height.HasValue) + { + error = what + " supplies only one of 'width' and 'height'. Give both or neither."; + return false; + } + + if (width is <= 0 || height is <= 0) + { + error = what + " has a width or height that is not positive."; + return false; + } + + return true; + } + + /// Reads an object's rectangle, or nulls when geometry is not being captured. + private static (int? X, int? Y, int? Width, int? Height) GeometryOf(Entity entity, bool includeGeometry) + => includeGeometry && entity is DrawingElement drawing + ? (drawing.Left, drawing.Top, drawing.Width, drawing.Height) + : (null, null, null, null); + + /// Expands a rectangle by a margin on every side. + private static Rect Grow(Rect rect, int margin) + => new Rect(rect.X - margin, rect.Y - margin, rect.Width + (2 * margin), rect.Height + (2 * margin)); + + /// Names an element in an error message, preferring whatever the author can search for. + private static string Describe(ManifestElement element) + => "Element '" + (element.Alias ?? element.Name ?? element.Kind ?? "(unnamed)") + "'"; + + /// Names a boundary in an error message. + private static string Describe(ManifestBoundary boundary) + => "Boundary '" + (boundary.Alias ?? boundary.Name ?? "(unnamed)") + "'"; + private static bool TryResolveKind(ManifestElement element, out StencilKind kind, out StencilDto? stencil, out string? error) { error = null; @@ -501,18 +876,112 @@ private static string KindNoun(Entity entity) return string.IsNullOrWhiteSpace(value) ? null : value; } - private sealed class BoundaryBox + private readonly struct Rect { - public BoundaryBox(int x, int y) + public Rect(int x, int y, int width, int height) { this.X = x; this.Y = y; + this.Width = width; + this.Height = height; } public int X { get; } public int Y { get; } + public int Width { get; } + + public int Height { get; } + + public int Right => this.X + this.Width; + + public int Bottom => this.Y + this.Height; + + public static Rect Union(Rect first, Rect second) + { + int x = Math.Min(first.X, second.X); + int y = Math.Min(first.Y, second.Y); + return new Rect(x, y, Math.Max(first.Right, second.Right) - x, Math.Max(first.Bottom, second.Bottom) - y); + } + + public bool Contains(Rect inner) + => inner.X >= this.X && inner.Y >= this.Y && inner.Right <= this.Right && inner.Bottom <= this.Bottom; + + public override string ToString() + => string.Create( + CultureInfo.InvariantCulture, + $"({this.X}, {this.Y}) {this.Width}x{this.Height}"); + } + + private sealed class PageContext + { + public PageContext(DrawingSurfaceModel surface) + { + this.Surface = surface; + } + + public DrawingSurfaceModel Surface { get; } + + /// Where the next auto-placed boundary starts. Each page stacks independently. + public int CursorY { get; set; } = 40; + + /// How many boundary-less elements this page has already placed. + public int Unassigned { get; set; } + } + + private sealed class ResolvedElement + { + public ResolvedElement(ManifestElement element, StencilKind kind, StencilDto? stencil) + { + this.Element = element; + this.Kind = kind; + this.Stencil = stencil; + } + + public ManifestElement Element { get; } + + public StencilKind Kind { get; } + + public StencilDto? Stencil { get; } + + /// The rectangle an explicitly positioned element occupies, at its declared or default size. + public Rect PlacedBounds() + { + (int width, int height) = DefaultSize(this.Kind); + return new Rect( + this.Element.X ?? 0, + this.Element.Y ?? 0, + this.Element.Width ?? width, + this.Element.Height ?? height); + } + + private static (int Width, int Height) DefaultSize(StencilKind kind) => kind switch + { + StencilKind.Process => (100, 60), + StencilKind.ExternalEntity => (120, 60), + StencilKind.DataStore => (120, 50), + _ => (220, 160), + }; + } + + private sealed class BoundaryBox + { + public BoundaryBox(Rect bounds, bool sizeWasDeclared) + { + this.Bounds = bounds; + this.SizeWasDeclared = sizeWasDeclared; + } + + public Rect Bounds { get; } + + /// Whether the author fixed the size, so members must fit rather than grow it. + public bool SizeWasDeclared { get; } + + public int X => this.Bounds.X; + + public int Y => this.Bounds.Y; + public int Cursor { get; set; } } } diff --git a/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs b/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs index abff507..9e4c749 100644 --- a/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs +++ b/test/ThreatModelForge.Cli.Tests/ApplyExportTest.cs @@ -2,12 +2,14 @@ namespace ThreatModelForge.Cli.Tests { using System; using System.Collections.Generic; + using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using Microsoft.VisualStudio.TestTools.UnitTesting; using ThreatModelForge.Engine; using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; /// /// Unit tests for the declarative manifest verbs (tmforge apply and tmforge export). @@ -56,6 +58,49 @@ public class ApplyExportTest "{\"alias\":\"element:Widget\",\"kind\":\"process\",\"name\":\"Aliased\"}," + "{\"kind\":\"process\",\"name\":\"Widget\"}]}"; + /// The same manifest carrying the envelope this build writes. + private const string VersionedManifest = + "{\"schema\":\"tmforge-manifest\",\"version\":1,\"name\":\"T\"," + + "\"elements\":[{\"alias\":\"a\",\"kind\":\"process\",\"name\":\"Proc\"}]}"; + + /// A canonical model, which is a different document that happens to look similar. + private const string ModelNotManifest = + "{\"schema\":\"tmforge-json\",\"version\":\"0.1\"," + + "\"elements\":[{\"id\":\"d\",\"kind\":\"datastore\",\"name\":\"Audit\"}],\"flows\":[]}"; + + /// + /// A rule pack: another schema whose version is numeric, so nothing about the manifest's own + /// types rejects it. This is the document that demonstrates why the envelope check exists. + /// + private const string RulePackNotManifest = + "{\"schema\":\"tmforge-rules\",\"version\":2,\"dialect\":\"urn:tmforge:rules:flat-v1\"," + + "\"pack\":{\"id\":\"p\",\"name\":\"P\",\"version\":\"1.0.0\"},\"rules\":[]}"; + + /// Two aliased flows between the same pair, so targeting one must not hit the other. + private const string TwoAliasedFlowsManifest = + "{\"name\":\"T\",\"elements\":[" + + "{\"alias\":\"a\",\"kind\":\"external\",\"name\":\"Client\"}," + + "{\"alias\":\"b\",\"kind\":\"process\",\"name\":\"Service\"}]," + + "\"flows\":[{\"alias\":\"primary\",\"from\":\"a\",\"to\":\"b\",\"name\":\"Request\"}," + + "{\"alias\":\"backup\",\"from\":\"a\",\"to\":\"b\",\"name\":\"Retry\"}]}"; + + /// + /// A manifest describing two pages, with objects on each. The second page carries a boundary as + /// well as elements: with boundaries only on the first page, building every boundary onto page + /// one would be indistinguishable from building it correctly. + /// + private const string TwoPageManifest = + "{\"schema\":\"tmforge-manifest\",\"version\":1,\"name\":\"T\"," + + "\"pages\":[{\"alias\":\"ctx\",\"name\":\"Context\"},{\"alias\":\"svc\",\"name\":\"Service\"}]," + + "\"boundaries\":[{\"alias\":\"tb\",\"name\":\"Edge\",\"page\":\"ctx\"}," + + "{\"alias\":\"vault\",\"name\":\"Vault\",\"page\":\"svc\"}]," + + "\"elements\":[" + + "{\"alias\":\"a\",\"kind\":\"external\",\"name\":\"Client\",\"page\":\"ctx\",\"boundary\":\"tb\"}," + + "{\"alias\":\"b\",\"kind\":\"process\",\"name\":\"Front\",\"page\":\"ctx\"}," + + "{\"kind\":\"process\",\"name\":\"Ledger\",\"page\":\"svc\",\"boundary\":\"vault\"}," + + "{\"kind\":\"store\",\"name\":\"Ledger DB\",\"page\":\"svc\"}]," + + "\"flows\":[{\"from\":\"a\",\"to\":\"b\",\"name\":\"Call\"}]}"; + /// /// Gets or sets the working directory created for each test. /// @@ -347,6 +392,438 @@ public void AnAliasShapedLikeAStructuralKeyDoesNotCollide() Assert.AreEqual(2, model.DrawingSurfaceList[0].Borders.Count, "an element was lost to an identifier collision"); } + /// + /// A manifest with no envelope is still read. The concise form predates the envelope and is the + /// documented starting point, so requiring one would break every manifest already written. + /// + [TestMethod] + public void AManifestWithoutAnEnvelopeIsStillRead() + { + Assert.IsTrue(ManifestSupport.TryRead(SampleManifest, out Manifest? manifest, out string? error), error); + Assert.AreEqual(3, manifest.Elements!.Count); + } + + /// A manifest carrying the current envelope is read. + [TestMethod] + public void AVersionedManifestIsRead() + { + Assert.IsTrue(ManifestSupport.TryRead(VersionedManifest, out Manifest? manifest, out string? error), error); + Assert.AreEqual(Manifest.SchemaName, manifest.Schema); + Assert.AreEqual(Manifest.CurrentVersion, manifest.Version); + } + + /// + /// A manifest from a newer build is refused with an upgrade hint rather than read on assumptions + /// that no longer hold — the whole reason to carry a version. + /// + [TestMethod] + public void AFutureSchemaVersionIsRefused() + { + string future = VersionedManifest.Replace("\"version\":1", "\"version\":2", StringComparison.Ordinal); + + Assert.IsFalse(ManifestSupport.TryRead(future, out _, out string? error)); + StringAssert.Contains(error, "cannot read"); + StringAssert.Contains(error, "Upgrade"); + } + + /// + /// A document of another schema is refused rather than coerced. The check runs against the raw + /// JSON before deserialization, so it reports the schema mismatch rather than whatever binding + /// error the foreign document happens to produce. + /// + [TestMethod] + public void ADocumentOfAnotherSchemaIsRefused() + { + Assert.IsFalse(ManifestSupport.TryRead(ModelNotManifest, out _, out string? error)); + StringAssert.Contains(error, "tmforge-manifest"); + StringAssert.Contains(error, "tmforge-json"); + } + + /// + /// The case the envelope earns its keep on. A rule pack carries a numeric version, so no type + /// mismatch rejects it: it deserializes into a manifest that simply declares nothing, and + /// without the check apply would build an empty model from it and report success. The + /// first assertion measures that rather than assuming it. + /// + [TestMethod] + public void ADocumentThatWouldOtherwiseCoerceIsRefused() + { + Manifest? coerced = ManifestSupport.Deserialize(RulePackNotManifest); + Assert.IsNotNull(coerced, "a rule pack does deserialize into a manifest shape"); + Assert.IsNull(coerced.Elements, "declaring nothing, which is what would have made this silent"); + + Assert.IsFalse(ManifestSupport.TryRead(RulePackNotManifest, out _, out string? error)); + StringAssert.Contains(error, "tmforge-rules"); + } + + /// Malformed JSON is reported as such rather than throwing out of the reader. + [TestMethod] + public void MalformedJsonIsReportedNotThrown() + { + Assert.IsFalse(ManifestSupport.TryRead("{ not json", out _, out string? error)); + StringAssert.Contains(error, "not valid JSON"); + } + + /// Export stamps the envelope, so a round-tripped manifest is versioned. + [TestMethod] + public void ExportStampsTheEnvelope() + { + Manifest source = ManifestSupport.Deserialize(SampleManifest) + ?? throw new InvalidOperationException("the sample manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(source, force: false, out ThreatModel model, out _, out string? error), error); + + Manifest exported = ManifestSupport.Extract(model); + + Assert.AreEqual(Manifest.SchemaName, exported.Schema); + Assert.AreEqual(Manifest.CurrentVersion, exported.Version); + StringAssert.Contains(ManifestSupport.Serialize(exported), "\"schema\": \"tmforge-manifest\""); + } + + /// A boundary's explicit rectangle is honoured rather than replaced by the stacking layout. + [TestMethod] + public void ExplicitBoundaryGeometryIsHonoured() + { + const string Placed = + "{\"name\":\"T\",\"boundaries\":[{\"alias\":\"tb\",\"name\":\"Edge\"," + + "\"x\":300,\"y\":250,\"width\":500,\"height\":400}]}"; + + Manifest manifest = ManifestSupport.Deserialize(Placed) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + DrawingElement boundary = model.DrawingSurfaceList[0].Borders.Values + .OfType().Single(element => element is BorderBoundary); + + Assert.AreEqual(300, boundary.Left); + Assert.AreEqual(250, boundary.Top); + Assert.AreEqual(500, boundary.Width); + Assert.AreEqual(400, boundary.Height); + } + + /// Explicit geometry is honoured instead of being replaced by automatic placement. + [TestMethod] + public void ExplicitGeometryIsHonoured() + { + const string Placed = + "{\"name\":\"T\",\"elements\":[" + + "{\"alias\":\"a\",\"kind\":\"process\",\"name\":\"Proc\",\"x\":640,\"y\":480,\"width\":90,\"height\":40}]}"; + + Manifest manifest = ManifestSupport.Deserialize(Placed) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + DrawingElement placed = model.DrawingSurfaceList[0].Borders.Values.OfType().Single(); + + Assert.AreEqual(640, placed.Left); + Assert.AreEqual(480, placed.Top); + Assert.AreEqual(90, placed.Width); + Assert.AreEqual(40, placed.Height); + } + + /// + /// Half a rectangle is refused. Defaulting the missing half would place the object somewhere the + /// author did not ask for while looking like the request had been honoured. + /// + [TestMethod] + public void HalfSuppliedGeometryIsRefused() + { + const string HalfPlaced = + "{\"name\":\"T\",\"elements\":[{\"alias\":\"a\",\"kind\":\"process\",\"name\":\"P\",\"x\":10}]}"; + + Manifest manifest = ManifestSupport.Deserialize(HalfPlaced) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsFalse(ManifestSupport.Build(manifest, force: false, out _, out _, out string? error)); + StringAssert.Contains(error, "only one of 'x' and 'y'"); + } + + /// An auto-sized boundary grows to contain a member placed outside its default box. + [TestMethod] + public void AnAutoSizedBoundaryGrowsToContainAnExplicitlyPlacedMember() + { + const string Mixed = + "{\"name\":\"T\",\"boundaries\":[{\"alias\":\"tb\",\"name\":\"Edge\"}]," + + "\"elements\":[{\"alias\":\"a\",\"kind\":\"process\",\"name\":\"P\",\"boundary\":\"tb\"," + + "\"x\":900,\"y\":700,\"width\":100,\"height\":60}]}"; + + Manifest manifest = ManifestSupport.Deserialize(Mixed) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + DrawingElement boundary = model.DrawingSurfaceList[0].Borders.Values + .OfType().Single(element => element is BorderBoundary); + + Assert.IsTrue( + boundary.Left + boundary.Width >= 1000 && boundary.Top + boundary.Height >= 760, + "the boundary did not grow to contain its member, so the member sits outside it"); + } + + /// + /// A fixed boundary too small for the members it must lay out is refused, because the grid would + /// place them outside the box the author drew — the one case where the manifest cannot be honoured. + /// + [TestMethod] + public void AFixedBoundaryTooSmallForItsAutoPlacedMembersIsRefused() + { + const string Cramped = + "{\"name\":\"T\",\"boundaries\":[{\"alias\":\"tb\",\"name\":\"Edge\",\"x\":0,\"y\":0,\"width\":40,\"height\":40}]," + + "\"elements\":[{\"alias\":\"a\",\"kind\":\"process\",\"name\":\"P\",\"boundary\":\"tb\"}]}"; + + Manifest manifest = ManifestSupport.Deserialize(Cramped) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsFalse(ManifestSupport.Build(manifest, force: false, out _, out _, out string? error)); + StringAssert.Contains(error, "need at least"); + StringAssert.Contains(error, "Boundary 'tb'"); + } + + /// + /// A member placed outside the boundary it belongs to is allowed. Nothing is clipped — both + /// objects go exactly where they were asked to — and refusing it would make a diagram that is + /// already in that state impossible to export and re-apply. + /// + [TestMethod] + public void AnExplicitlyPlacedMemberOutsideItsFixedBoundaryIsAllowed() + { + const string Outside = + "{\"name\":\"T\",\"boundaries\":[{\"alias\":\"tb\",\"name\":\"Edge\",\"x\":0,\"y\":0,\"width\":400,\"height\":300}]," + + "\"elements\":[{\"alias\":\"a\",\"kind\":\"process\",\"name\":\"P\",\"boundary\":\"tb\"," + + "\"x\":900,\"y\":700,\"width\":100,\"height\":60}]}"; + + Manifest manifest = ManifestSupport.Deserialize(Outside) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + Assert.AreEqual(2, model.DrawingSurfaceList[0].Borders.Count); + } + + /// Export omits geometry by default and records it on request. + [TestMethod] + public void ExportRecordsGeometryOnlyWhenAsked() + { + Manifest source = ManifestSupport.Deserialize(SampleManifest) + ?? throw new InvalidOperationException("the sample manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(source, force: false, out ThreatModel model, out _, out string? error), error); + + Assert.IsNull(ManifestSupport.Extract(model).Elements![0].X, "the default export leaked geometry"); + Assert.IsNotNull(ManifestSupport.Extract(model, includeGeometry: true).Elements![0].X); + } + + /// + /// Export with geometry, apply, and export again produces the same manifest — the round trip the + /// whole feature exists to make lossless. + /// + [TestMethod] + public void ExportApplyExportIsStableWithGeometry() + { + Manifest source = ManifestSupport.Deserialize(SampleManifest) + ?? throw new InvalidOperationException("the sample manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(source, force: false, out ThreatModel first, out _, out string? error), error); + + string once = ManifestSupport.Serialize(ManifestSupport.Extract(first, includeGeometry: true)); + + Manifest reread = ManifestSupport.Deserialize(once) + ?? throw new InvalidOperationException("the exported manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(reread, force: false, out ThreatModel second, out _, out error), error); + + Assert.AreEqual(once, ManifestSupport.Serialize(ManifestSupport.Extract(second, includeGeometry: true))); + } + + /// Objects land on the page they declare, rather than all on the first one. + [TestMethod] + public void ObjectsAreBuiltOntoTheirDeclaredPage() + { + Manifest manifest = ManifestSupport.Deserialize(TwoPageManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + Assert.AreEqual(2, model.DrawingSurfaceList.Count); + Assert.AreEqual("Context", model.DrawingSurfaceList[0].Header); + Assert.AreEqual("Service", model.DrawingSurfaceList[1].Header); + Assert.AreEqual(3, model.DrawingSurfaceList[0].Borders.Count, "the Edge boundary and its two elements belong on Context"); + Assert.AreEqual(3, model.DrawingSurfaceList[1].Borders.Count, "the Vault boundary and its two elements belong on Service"); + Assert.AreEqual(1, model.DrawingSurfaceList[0].Lines.Count, "the flow belongs on the page its endpoints are on"); + Assert.AreEqual( + 1, + model.DrawingSurfaceList[1].Borders.Values.OfType().Count(), + "the Vault boundary was not built onto the second page"); + } + + /// + /// A multi-page manifest applied twice reproduces every identifier, on every page. + /// + /// + /// This caught a real defect: alias-less elements on the second page were being re-keyed + /// against the first page's surface, where they do not exist, so the re-key silently did + /// nothing and they kept the minted guid it exists to replace. Counting elements did not + /// notice — they were all present, just unstable — which is why this asserts the ids. + /// + [TestMethod] + public void AMultiPageManifestAppliedTwiceReproducesEveryIdentifier() + { + Manifest manifest = ManifestSupport.Deserialize(TwoPageManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel first, out _, out string? error), error); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel second, out _, out error), error); + + for (int page = 0; page < first.DrawingSurfaceList.Count; page++) + { + Assert.AreEqual( + first.DrawingSurfaceList[page].Guid, + second.DrawingSurfaceList[page].Guid, + "page " + page.ToString(CultureInfo.InvariantCulture) + " changed identity"); + CollectionAssert.AreEquivalent( + first.DrawingSurfaceList[page].Borders.Keys.ToList(), + second.DrawingSurfaceList[page].Borders.Keys.ToList(), + "component identifiers moved on page " + page.ToString(CultureInfo.InvariantCulture)); + } + } + + /// + /// A flow whose endpoints sit on different pages is refused. A connector belongs to one + /// surface, so drawing it on whichever page resolved first would leave its endpoints elsewhere. + /// + [TestMethod] + public void AFlowCrossingPagesIsRefused() + { + string crossing = TwoPageManifest.Replace( + "\"flows\":[{\"from\":\"a\",\"to\":\"b\",\"name\":\"Call\"}]", + "\"flows\":[{\"from\":\"a\",\"to\":\"Ledger\",\"name\":\"Call\"}]", + StringComparison.Ordinal); + Manifest manifest = ManifestSupport.Deserialize(crossing) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsFalse(ManifestSupport.Build(manifest, force: false, out _, out _, out string? error)); + StringAssert.Contains(error, "cannot cross pages"); + } + + /// An object naming a page the manifest never declares is refused, not silently moved. + [TestMethod] + public void AnUnknownPageReferenceIsRefused() + { + string unknown = TwoPageManifest.Replace("\"page\":\"svc\"", "\"page\":\"nope\"", StringComparison.Ordinal); + Manifest manifest = ManifestSupport.Deserialize(unknown) + ?? throw new InvalidOperationException("the manifest must parse"); + + Assert.IsFalse(ManifestSupport.Build(manifest, force: false, out _, out _, out string? error)); + StringAssert.Contains(error, "does not declare"); + } + + /// + /// A single-page model exports exactly the shape it always did, with no pages block and no page + /// reference on any object, so a manifest written before pages existed still reads identically. + /// + [TestMethod] + public void ASinglePageModelExportsWithoutPages() + { + Manifest source = ManifestSupport.Deserialize(SampleManifest) + ?? throw new InvalidOperationException("the sample manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(source, force: false, out ThreatModel model, out _, out string? error), error); + + Manifest exported = ManifestSupport.Extract(model); + + Assert.IsNull(exported.Pages, "a single-page model must not grow a pages block"); + Assert.IsTrue(exported.Elements!.TrueForAll(element => element.Page == null)); + } + + /// A multi-page model round-trips its pages through export and apply. + [TestMethod] + public void PagesSurviveExportAndApply() + { + Manifest source = ManifestSupport.Deserialize(TwoPageManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(source, force: false, out ThreatModel model, out _, out string? error), error); + + Manifest exported = ManifestSupport.Extract(model); + Assert.AreEqual(2, exported.Pages!.Count); + + Assert.IsTrue(ManifestSupport.Build(exported, force: false, out ThreatModel rebuilt, out _, out error), error); + + Assert.AreEqual(2, rebuilt.DrawingSurfaceList.Count); + Assert.AreEqual( + model.DrawingSurfaceList[1].Borders.Count, + rebuilt.DrawingSurfaceList[1].Borders.Count, + "the second page lost or gained members on the round trip"); + } + + /// + /// Re-keying against a surface that does not hold the object throws rather than doing nothing. + /// The silent version is what let the multi-page defect above go unnoticed. + /// + [TestMethod] + public void RekeyingAgainstTheWrongSurfaceThrows() + { + Manifest manifest = ManifestSupport.Deserialize(TwoPageManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + Guid onSecondPage = model.DrawingSurfaceList[1].Borders.Keys.First(); + + Assert.ThrowsExactly( + () => AuthoringSupport.RekeyComponent(model.DrawingSurfaceList[0], onSecondPage, Guid.NewGuid())); + } + + /// + /// A flow alias resolves to that connector, so the authoring verbs can target a flow the way + /// they target an element. + /// + /// + /// The resolver has always scanned connectors as well as components, but until flows could + /// carry an alias that branch could never match — it was unreachable rather than working. This + /// pins the behaviour now that it is live, so narrowing the resolver to components would fail + /// here instead of quietly removing the only way to name a flow. + /// + [TestMethod] + public void AFlowIsResolvableByItsAlias() + { + Manifest manifest = ManifestSupport.Deserialize(TwoAliasedFlowsManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + Assert.IsTrue(AuthoringSupport.TryResolveElementId(model, null, "primary", out Guid id, out error), error); + + DrawingSurfaceModel page = model.DrawingSurfaceList[0]; + Assert.IsTrue(page.Lines.ContainsKey(id), "the alias resolved to something that is not a connector"); + Assert.AreEqual(AuthoringSupport.DeterministicId("primary"), id); + } + + /// Targeting one flow alias does not touch the other flow between the same pair. + [TestMethod] + public void AFlowAliasTargetsOnlyThatFlow() + { + Manifest manifest = ManifestSupport.Deserialize(TwoAliasedFlowsManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + Assert.IsTrue(AuthoringSupport.TryResolveElementId(model, null, "primary", out Guid primary, out error), error); + Assert.IsTrue(AuthoringSupport.TryResolveElementId(model, null, "backup", out Guid backup, out error), error); + + Assert.AreNotEqual(primary, backup, "two aliases resolved to one connector"); + } + + /// Removing by flow alias removes that flow and leaves its endpoints standing. + [TestMethod] + public void RemovingByFlowAliasRemovesOnlyThatFlow() + { + Manifest manifest = ManifestSupport.Deserialize(TwoAliasedFlowsManifest) + ?? throw new InvalidOperationException("the manifest must parse"); + Assert.IsTrue(ManifestSupport.Build(manifest, force: false, out ThreatModel model, out _, out string? error), error); + + Assert.IsTrue( + AuthoringOperations.Remove(model, new RemoveRequest { Id = "backup" }, out IReadOnlyList removed, out error), + error); + + DrawingSurfaceModel page = model.DrawingSurfaceList[0]; + + Assert.AreEqual(1, removed.Count, "removing a flow must not take anything else with it"); + Assert.AreEqual(2, page.Borders.Count, "the endpoints must survive removing the flow between them"); + Assert.AreEqual(1, page.Lines.Count); + Assert.IsTrue( + page.Lines.ContainsKey(AuthoringSupport.DeterministicId("primary")), + "the wrong flow was removed"); + } + private static JsonElement OpenData(string path) { (int exit, string stdout) = Capture(() => OpenCommand.Run(new[] { "--json", path }));