diff --git a/.gitignore b/.gitignore index 95d7b70b..44f08569 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ bin/ /azdo.exe **/*.local.* +**/.env* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c46552e6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,38 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: + // https://go.microsoft.com/fwlink/?linkid=830387 + // https://nsddd.top/posts/use-go-tools-dlv/ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug ACC Test", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${fileDirname}", + "args": ["-test.run", "^${selectedText}$"], + "env": { + "AZDO_ACC_TEST": "1", + "AZDO_DEBUG": "1" + }, + "envFile": "${workspaceFolder}/.env" + }, + { + "name": "Debug ACC Test (Proxy)", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${fileDirname}", + "args": ["-test.run", "^${selectedText}$"], + "env": { + "AZDO_ACC_TEST": "1", + "AZDO_DEBUG": "1", + "HTTPS_PROXY": "http://127.0.0.1:8080", + "HTTP_PROXY": "http://127.0.0.1:8080" + }, + "envFile": "${workspaceFolder}/.env" + } + ] +} diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index d6079858..a510a877 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -733,6 +733,25 @@ Aliases s ``` +#### `azdo security permission update [flags]` + +Update or create permissions for a user or group. + +``` + --allow-bit strings Permission bit or comma-separated bits to allow. + --deny-bit strings Permission bit or comma-separated bits to deny. + --merge Merge incoming ACEs with existing entries or replace the permissions. If provided without value true is implied. +-n, --namespace-id string ID of the security namespace to modify (required). + --token string Security token for the resource (required). +-y, --yes Do not prompt for confirmation. +``` + +Aliases + +``` +create, u, new +``` + ### See also diff --git a/docs/azdo_security_permission.md b/docs/azdo_security_permission.md index fa2e93c5..99518b12 100644 --- a/docs/azdo_security_permission.md +++ b/docs/azdo_security_permission.md @@ -7,6 +7,7 @@ Manage Azure DevOps security permissions. * [azdo security permission list](./azdo_security_permission_list.md) * [azdo security permission namespace](./azdo_security_permission_namespace.md) * [azdo security permission show](./azdo_security_permission_show.md) +* [azdo security permission update](./azdo_security_permission_update.md) ### ALIASES diff --git a/docs/azdo_security_permission_update.md b/docs/azdo_security_permission_update.md new file mode 100644 index 00000000..d16c0bed --- /dev/null +++ b/docs/azdo_security_permission_update.md @@ -0,0 +1,86 @@ +## Command `azdo security permission update` + +``` +azdo security permission update [flags] +``` + + Update the permissions for a user or group on a specific securable resource (identified by a token) by assigning "allow" or "deny" permission bits. + + The --allow-bit and --deny-bit flags accept one or more permission values. Each value may be provided as: + - a hexadecimal bitmask (e.g. 0x4), + - a decimal bit value (e.g. 4), or + - a textual action name matching the namespace action (e.g. "Read", "Edit"). + + To discover the available actions (and their textual names) for a security namespace, use: + azdo security permission namespace show --namespace-id + + Accepted TARGET formats: + - ORGANIZATION/SUBJECT → target subject in org + - ORGANIZATION/PROJECT/SUBJECT → subject scoped to project + +Token hierarchy (Git repo namespace example): + - repoV2 → all repos across org + - repoV2/{projectId} → all repos in project + - repoV2/{projectId}/{repoId} → single repo in project + + + - ORGANIZATION/SUBJECT → target subject in org + - ORGANIZATION/PROJECT/SUBJECT → subject scoped to project + + +### Options + + +* `--allow-bit` `strings` + + Permission bit or comma-separated bits to allow. + +* `--deny-bit` `strings` + + Permission bit or comma-separated bits to deny. + +* `--merge` + + Merge incoming ACEs with existing entries or replace the permissions. If provided without value true is implied. + +* `-n`, `--namespace-id` `string` + + ID of the security namespace to modify (required). + +* `--token` `string` + + Security token for the resource (required). + +* `-y`, `--yes` + + Do not prompt for confirmation. + + +### ALIASES + +- `create` +- `u` +- `new` + +### Examples + +```bash +# Allow the Read action (textual) for a user on a token +azdo security permission update fabrikam/contoso@example.com --namespace-id 71356614-aad7-4757-8f2c-0fb3bff6f680 --token '$/696416ee-f7ff-4ee3-934a-979b00dce74f' --allow-bit Read + +# Allow multiple actions by specifying --allow-bit multiple times (textual and numeric) +azdo security permission update fabrikam/contoso@example.com --namespace-id bf7bfa03-b2b7-47db-8113-fa2e002cc5b1 --token vstfs:///Classification/Node/18c76992-93fa-4eb2-aac0-0abc0be212d6 --allow-bit Read --allow-bit Contribute --allow-bit 0x4 + +# Allow multiple actions using a single comma-separated value (shells may need quoting) +azdo security permission update fabrikam/contoso@example.com --namespace-id 302acaca-b667-436d-a946-87133492041c --token BuildPrivileges --allow-bit "Read,Contribute,0x4" + +# Deny a numeric bit and merge with existing ACEs (merge will OR incoming bits with existing ACE) +azdo security permission update fabrikam/contoso@example.com --namespace-id 33344d9c-fc72-4d6f-aba5-fa317101a7e9 --token '696416ee-f7ff-4ee3-934a-979b00dce74f/237' --deny-bit 8 --merge + +# Use --yes to skip confirmation prompts +azdo security permission update fabrikam/contoso@example.com --namespace-id 8adf73b7-389a-4276-b638-fe1653f7efc7 --token '$/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd/00000000-0000-0000-0000-000000000000' --allow-bit Read --yes +``` + +### See also + +* [azdo security permission](./azdo_security_permission.md) diff --git a/docs/security-acls.md b/docs/security-acls.md new file mode 100644 index 00000000..5c50ba9f --- /dev/null +++ b/docs/security-acls.md @@ -0,0 +1,1301 @@ +# Azure DevOps Security Namespaces, ACLs and ACEs + +This document explains Azure DevOps security namespaces, access control lists (ACLs), and access control entries (ACEs) in plain language and with concrete examples. It's written for readers who have never worked with Azure DevOps security before. + +- [Key concepts](#key-concepts) +- [How ACEs represent permissions](#how-aces-represent-permissions) +- [APIs and SDKs](#apis-and-sdks) +- [Create vs Update ACEs — merge semantics](#create-vs-update-aces--merge-semantics) +- [How clients (and CLI) typically work](#how-clients-and-cli-typically-work) +- [Example request body shape (JSON)](#example-request-body-shape-json) +- [Safety and best practices](#safety-and-best-practices) +- [Troubleshooting](#troubleshooting) +- [References](#references) +- [Security namespace structure (detailed)](#security-namespace-structure-detailed) + - [What are security bits?](#what-are-security-bits) + - [How ACEs are constructed from namespaces, bits, and a subject](#how-aces-are-constructed-from-namespaces-bits-and-a-subject) + - [How are ACE tokens generated?](#how-are-ace-tokens-generated) + - [Additional reading \& references](#additional-reading--references) +- [authoritative references and REST endpoints](#authoritative-references-and-rest-endpoints) +- [Concrete examples](#concrete-examples) +- [Notes on tokens and token formation](#notes-on-tokens-and-token-formation) +- [Small warnings and handy links](#small-warnings-and-handy-links) +- [Quick checklist (direct msdocs links)](#quick-checklist-direct-msdocs-links) +- [Using the `azdo` CLI to work with namespaces and ACEs](#using-the-azdo-cli-to-work-with-namespaces-and-aces) +- [Git repository token pattern (example)](#git-repository-token-pattern-example) +- [Token structures observed per namespace](#token-structures-observed-per-namespace) + - [Namespace 58450c49-b02d-465a-ab12-59ae512d6531 (Analytics)](#namespace-58450c49-b02d-465a-ab12-59ae512d6531-analytics) + - [Namespace d34d3680-dfe5-4cc6-a949-7d9c68f73cba (AnalyticsViews)](#namespace-d34d3680-dfe5-4cc6-a949-7d9c68f73cba-analyticsviews) + - [Namespace 62a7ad6b-8b8d-426b-ba10-76a7090e94d5 (PipelineCachePrivileges)](#namespace-62a7ad6b-8b8d-426b-ba10-76a7090e94d5-pipelinecacheprivileges) + - [Namespace 7c7d32f7-0e86-4cd6-892e-b35dbba870bd (ReleaseManagement)](#namespace-7c7d32f7-0e86-4cd6-892e-b35dbba870bd-releasemanagement) + - [Namespace c788c23e-1b46-4162-8f5e-d7585343b5de (ReleaseManagement)](#namespace-c788c23e-1b46-4162-8f5e-d7585343b5de-releasemanagement) + - [Namespace a6cc6381-a1ca-4b36-b3c1-4e65211e82b6 (AuditLog)](#namespace-a6cc6381-a1ca-4b36-b3c1-4e65211e82b6-auditlog) + - [Namespace 445d2788-c5fb-4132-bbef-09c4045ad93f (WorkItemTrackingAdministration)](#namespace-445d2788-c5fb-4132-bbef-09c4045ad93f-workitemtrackingadministration) + - [Namespace 2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 (Git Repositories)](#namespace-2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87-git-repositories) + - [Namespace 3c15a8b7-af1a-45c2-aa97-2cb97078332e (VersionControlItems2)](#namespace-3c15a8b7-af1a-45c2-aa97-2cb97078332e-versioncontrolitems2) + - [Namespace 2bf24a2b-70ba-43d3-ad97-3d9e1f75622f (EventSubscriber)](#namespace-2bf24a2b-70ba-43d3-ad97-3d9e1f75622f-eventsubscriber) + - [Namespace 5a6cd233-6615-414d-9393-48dbb252bd23 (WorkItemTrackingProvision)](#namespace-5a6cd233-6615-414d-9393-48dbb252bd23-workitemtrackingprovision) + - [Namespace 49b48001-ca20-4adc-8111-5b60c903a50c (ServiceEndpoints)](#namespace-49b48001-ca20-4adc-8111-5b60c903a50c-serviceendpoints) + - [Namespace cb594ebe-87dd-4fc9-ac2c-6a10a4c92046 (ServiceHooks)](#namespace-cb594ebe-87dd-4fc9-ac2c-6a10a4c92046-servicehooks) + - [Namespace 3e65f728-f8bc-4ecd-8764-7e378b19bfa7 (Collection)](#namespace-3e65f728-f8bc-4ecd-8764-7e378b19bfa7-collection) + - [Namespace cb4d56d2-e84b-457e-8845-81320a133fbb (Proxy)](#namespace-cb4d56d2-e84b-457e-8845-81320a133fbb-proxy) + - [Namespace bed337f8-e5f3-4fb9-80da-81e17d06e7a8 (Plan)](#namespace-bed337f8-e5f3-4fb9-80da-81e17d06e7a8-plan) + - [Namespace 2dab47f9-bd70-49ed-9bd5-8eb051e59c02 (Process)](#namespace-2dab47f9-bd70-49ed-9bd5-8eb051e59c02-process) + - [Namespace 11238e09-49f2-40c7-94d0-8f0307204ce4 (AccountAdminSecurity)](#namespace-11238e09-49f2-40c7-94d0-8f0307204ce4-accountadminsecurity) + - [Namespace b7e84409-6553-448a-bbb2-af228e07cbeb (Library)](#namespace-b7e84409-6553-448a-bbb2-af228e07cbeb-library) + - [Namespace 83d4c2e6-e57d-4d6e-892b-b87222b7ad20 (Environment)](#namespace-83d4c2e6-e57d-4d6e-892b-b87222b7ad20-environment) + - [Namespace 58b176e7-3411-457a-89d0-c6d0ccb3c52b (EventSubscription)](#namespace-58b176e7-3411-457a-89d0-c6d0ccb3c52b-eventsubscription) + - [Namespace 83e28ad4-2d72-4ceb-97b0-c7726d5502c3 (CSS)](#namespace-83e28ad4-2d72-4ceb-97b0-c7726d5502c3-css) + - [Namespace 9e4894c3-ff9a-4eac-8a85-ce11cafdc6f1 (TeamLabSecurity)](#namespace-9e4894c3-ff9a-4eac-8a85-ce11cafdc6f1-teamlabsecurity) + - [Namespace fc5b7b85-5d6b-41eb-8534-e128cb10eb67 (ProjectAnalysisLanguageMetrics)](#namespace-fc5b7b85-5d6b-41eb-8534-e128cb10eb67-projectanalysislanguagemetrics) + - [Namespace bb50f182-8e5e-40b8-bc21-e8752a1e7ae2 (Tagging)](#namespace-bb50f182-8e5e-40b8-bc21-e8752a1e7ae2-tagging) + - [Namespace f6a4de49-dbe2-4704-86dc-f8ec1a294436 (MetaTask)](#namespace-f6a4de49-dbe2-4704-86dc-f8ec1a294436-metatask) + - [Namespace bf7bfa03-b2b7-47db-8113-fa2e002cc5b1 (Iteration)](#namespace-bf7bfa03-b2b7-47db-8113-fa2e002cc5b1-iteration) + - [Namespace 71356614-aad7-4757-8f2c-0fb3bff6f680 (WorkItemQueryFolders)](#namespace-71356614-aad7-4757-8f2c-0fb3bff6f680-workitemqueryfolders) + - [Namespace fa557b48-b5bf-458a-bb2b-1b680426fe8b (Favorites)](#namespace-fa557b48-b5bf-458a-bb2b-1b680426fe8b-favorites) + - [Namespace 4ae0db5d-8437-4ee8-a18b-1f6fb38bd34c (Registry)](#namespace-4ae0db5d-8437-4ee8-a18b-1f6fb38bd34c-registry) + - [Namespace c2ee56c9-e8fa-4cdd-9d48-2c44f697a58e (Graph)](#namespace-c2ee56c9-e8fa-4cdd-9d48-2c44f697a58e-graph) + - [Namespace dc02bf3d-cd48-46c3-8a41-345094ecc94b (ViewActivityPaneSecurity)](#namespace-dc02bf3d-cd48-46c3-8a41-345094ecc94b-viewactivitypanesecurity) + - [Namespace 2a887f97-db68-4b7c-9ae3-5cebd7add999 (Job)](#namespace-2a887f97-db68-4b7c-9ae3-5cebd7add999-job) + - [Namespace 7cd317f2-adc6-4b6c-8d99-6074faeaf173 (EventPublish)](#namespace-7cd317f2-adc6-4b6c-8d99-6074faeaf173-eventpublish) + - [Namespace 73e71c45-d483-40d5-bdba-62fd076f7f87 (WorkItemTracking)](#namespace-73e71c45-d483-40d5-bdba-62fd076f7f87-workitemtracking) + - [Namespace 4a9e8381-289a-4dfd-8460-69028eaa93b3 (StrongBox)](#namespace-4a9e8381-289a-4dfd-8460-69028eaa93b3-strongbox) + - [Namespace 1f4179b3-6bac-4d01-b421-71ea09171400 (Server)](#namespace-1f4179b3-6bac-4d01-b421-71ea09171400-server) + - [Namespace e06e1c24-e93d-4e4a-908a-7d951187b483 (TestManagement)](#namespace-e06e1c24-e93d-4e4a-908a-7d951187b483-testmanagement) + - [Namespace 6ec4592e-048c-434e-8e6c-8671753a8418 (SettingEntries)](#namespace-6ec4592e-048c-434e-8e6c-8671753a8418-settingentries) + - [Namespace 302acaca-b667-436d-a946-87133492041c (BuildAdministration)](#namespace-302acaca-b667-436d-a946-87133492041c-buildadministration) + - [Namespace 2725d2bc-7520-4af4-b0e3-8d876494731f (Location)](#namespace-2725d2bc-7520-4af4-b0e3-8d876494731f-location) + - [Namespace 251e12d9-bea3-43a8-bfdb-901b98c0125e (Boards)](#namespace-251e12d9-bea3-43a8-bfdb-901b98c0125e-boards) + - [Namespace f0003bce-5f45-4f93-a25d-90fc33fe3aa9 (OrganizationLevelData)](#namespace-f0003bce-5f45-4f93-a25d-90fc33fe3aa9-organizationleveldata) + - [Namespace 83abde3a-4593-424e-b45f-9898af99034d (UtilizationPermissions)](#namespace-83abde3a-4593-424e-b45f-9898af99034d-utilizationpermissions) + - [Namespace c0e7a722-1cad-4ae6-b340-a8467501e7ce (WorkItemsHub)](#namespace-c0e7a722-1cad-4ae6-b340-a8467501e7ce-workitemshub) + - [Namespace 0582eb05-c896-449a-b933-aa3d99e121d6 (WebPlatform)](#namespace-0582eb05-c896-449a-b933-aa3d99e121d6-webplatform) + - [Namespace 66312704-deb5-43f9-b51c-ab4ff5e351c3 (VersionControlPrivileges)](#namespace-66312704-deb5-43f9-b51c-ab4ff5e351c3-versioncontrolprivileges) + - [Namespace 93bafc04-9075-403a-9367-b7164eac6b5c (Workspaces)](#namespace-93bafc04-9075-403a-9367-b7164eac6b5c-workspaces) + - [Namespace 093cbb02-722b-4ad6-9f88-bc452043fa63 (CrossProjectWidgetView)](#namespace-093cbb02-722b-4ad6-9f88-bc452043fa63-crossprojectwidgetview) + - [Namespace 35e35e8e-686d-4b01-aff6-c369d6e36ce0 (WorkItemTrackingConfiguration)](#namespace-35e35e8e-686d-4b01-aff6-c369d6e36ce0-workitemtrackingconfiguration) + - [Namespace 0d140cae-8ac1-4f48-b6d1-c93ce0301a12 (Discussion Threads)](#namespace-0d140cae-8ac1-4f48-b6d1-c93ce0301a12-discussion-threads) + - [Namespace 5ab15bc8-4ea1-d0f3-8344-cab8fe976877 (BoardsExternalIntegration)](#namespace-5ab15bc8-4ea1-d0f3-8344-cab8fe976877-boardsexternalintegration) + - [Namespace 7ffa7cf4-317c-4fea-8f1d-cfda50cfa956 (DataProvider)](#namespace-7ffa7cf4-317c-4fea-8f1d-cfda50cfa956-dataprovider) + - [Namespace 81c27cc8-7a9f-48ee-b63f-df1e1d0412dd (Social)](#namespace-81c27cc8-7a9f-48ee-b63f-df1e1d0412dd-social) + - [Namespace 9a82c708-bfbe-4f31-984c-e860c2196781 (Security)](#namespace-9a82c708-bfbe-4f31-984c-e860c2196781-security) + - [Namespace a60e0d84-c2f8-48e4-9c0c-f32da48d5fd1 (IdentityPicker)](#namespace-a60e0d84-c2f8-48e4-9c0c-f32da48d5fd1-identitypicker) + - [Namespace 84cc1aa4-15bc-423d-90d9-f97c450fc729 (ServicingOrchestration)](#namespace-84cc1aa4-15bc-423d-90d9-f97c450fc729-servicingorchestration) + - [Namespace 33344d9c-fc72-4d6f-aba5-fa317101a7e9 (Build)](#namespace-33344d9c-fc72-4d6f-aba5-fa317101a7e9-build) + - [Namespace 8adf73b7-389a-4276-b638-fe1653f7efc7 (DashboardsPrivileges)](#namespace-8adf73b7-389a-4276-b638-fe1653f7efc7-dashboardsprivileges) + - [Namespace 52d39943-cb85-4d7f-8fa8-c6baac873819 (Project)](#namespace-52d39943-cb85-4d7f-8fa8-c6baac873819-project) + - [Namespace a39371cf-0841-4c16-bbd3-276e341bc052 (VersionControlItems)](#namespace-a39371cf-0841-4c16-bbd3-276e341bc052-versioncontrolitems) +- [Authoritative reference and namespace examples](#authoritative-reference-and-namespace-examples) + - [Small canonical mapping](#small-canonical-mapping) + +## Key concepts + +- Security namespace: a logical grouping of related permissions for a resource type (for example, Git repositories, work items, pipelines). Each namespace defines a set of actions that can be allowed or denied. + +- Action: a single permission that can be granted or denied (for example, `Read`, `Contribute`, `Edit`). Each action is represented by a bitmask integer (e.g., `0x4`) and also has textual metadata (`Name` and `DisplayName`). Names live in the namespace's action definitions. + +- Token: a string that identifies a particular securable resource within a namespace (for example a project ID, a repository path, or `/` for the root). An ACL is always associated with a single namespace and a single token. + +- ACL (Access Control List): a container for ACEs that applies to a specific `token` within a namespace. An ACL includes properties such as `InheritPermissions` and a dictionary of ACEs keyed by descriptor. + +- ACE (Access Control Entry): an entry in an ACL for a single identity (user or group). An ACE contains: + - `Descriptor` — the identity descriptor (a unique string the server uses to identify a user or group) + - `Allow` — an integer bitmask of allowed actions + - `Deny` — an integer bitmask of denied actions + +## How ACEs represent permissions + +An ACE uses two integer bitmasks: `Allow` and `Deny`. +- Each action defined by the namespace corresponds to a single bit (for example `Bit 4` or `0x4`). +- To allow multiple actions, the bits are OR'd together. Example: `Read (0x1)` + `Contribute (0x4)` → `0x1 | 0x4 = 0x5`. +- Deny bits work similarly; the server evaluates both allow and deny masks when computing effective permissions (deny typically takes precedence for the specific actions denied). + +Because bitmasks are compact but hard to read, the Azure DevOps APIs return action definitions so clients can translate between integer bitmasks and human-friendly names. + +## APIs and SDKs + +Azure DevOps provides REST APIs and SDKs to inspect namespaces/actions and to manage ACLs/ACEs. Two important operations are: + +- QuerySecurityNamespaces (GET): returns namespace metadata including an `Actions` list. Each action has `Bit`, `Name`, and `DisplayName` fields. Use this to map textual names to the integer bits. + +- SetAccessControlEntries (POST): add or update ACEs in an ACL for a given `token`. The request body includes the `token` and one or more ACEs. The call supports an optional `merge` parameter that controls collision behavior. + +## Create vs Update ACEs — merge semantics + +- Creating a new ACE: call `SetAccessControlEntries` with an ACE whose `Descriptor` is the user/group descriptor and the desired `Allow`/`Deny` bitmasks. If no ACE for that descriptor exists on the ACL, the server will create it. + +- Updating an existing ACE: use `SetAccessControlEntries` as well. If an ACE already exists for the descriptor, the server's behavior depends on the `merge` parameter: + - `merge=true`: the server merges incoming `Allow` and `Deny` masks with the existing ones (effectively a bitwise OR). + - `merge=false` (or omitted): the incoming ACE replaces the existing ACE for that descriptor (displaces the old `Allow` and `Deny`). + +The SDK's documentation explains this: SetAccessControlEntries "Add or update ACEs in the ACL for the provided token... In the case of a collision (by identity descriptor) with an existing ACE in the ACL, the 'merge' parameter determines the behavior. If set, the existing ACE has its allow and deny merged with the incoming ACE's allow and deny. If unset, the existing ACE is displaced." (azure-devops-go-api vendor docs) + +## How clients (and CLI) typically work + +A typical sequence to add or update ACEs for a subject via a CLI or SDK: + +1. Identify the security namespace UUID for the resource type you want to operate on. Use existing documentation or listing commands (or the REST API) to find the namespace. + +2. (Optional, recommended) Query the namespace actions with `QuerySecurityNamespaces --namespace-id ` so you can accept or display textual action names. The response includes action definitions `{ Bit, Name, DisplayName }`. + +3. Resolve the subject to the ACL descriptor expected by the security API: + - For a user or group string input (e.g. email or project-scoped group id), call `Extensions.ResolveMemberDescriptor` to canonicalize the input to a graph descriptor. + - Call `Identity.ReadIdentities` with that graph descriptor to retrieve the identity's `Descriptor` field — this is the value used in ACEs. + +4. Convert user-supplied permission tokens to an integer bitmask: + - Acceptable input formats: hexadecimal (prefixed with `0x`), decimal integers, or textual action names that match the namespace's `ActionDefinition.Name` or `DisplayName`. + - Map textual names to their corresponding `Bit` values and OR them together to produce a single `Allow` or `Deny` integer. + +5. Build the ACE object: `{ descriptor: "", allow: , deny: }` and include it in the `SetAccessControlEntries` request body along with the `token`. + +6. Call `SetAccessControlEntries` with `merge=true` if you want to merge with existing ACEs; omit or set false if you want to replace. + +## Example request body shape (JSON) + +```json +{ + "token": "/projects/00000000-0000-0000-0000-000000000000", + "aces": [ + { + "descriptor": "Microsoft.IdentityModel.Claims.ClaimsIdentity;...", + "allow": 5, + "deny": 0 + } + ], + "merge": true +} +``` + +Notes: the exact shape accepted by the server may vary across API versions; the Go SDK implements marshalling for you if you use its `SetAccessControlEntries` method. + +## Safety and best practices + +- Always prefer querying action definitions and using textual names in CLI/UI — this reduces the chance of granting the wrong numerical bitmask. +- When adding denies, be careful: denies can block permissions even if a group grants them via inheritance. Prefer minimal denial and prefer group-based permissions where possible. +- Use `merge=true` when you want to add specific allow/deny bits without wiping any other permissions for the identity. +- Ensure you have the required admin permissions to change ACLs (for example Manage permissions or Edit project-level info depending on the scope). +- Never hardcode PATs or secrets in scripts; use secure authentication (MS Entra/Azure AD) when possible. + +## Troubleshooting + +- If the server reports an error resolving a descriptor, double-check the identity input and use the Identity/Extensions APIs to verify the descriptor you will use. +- If action names don't match, call `QuerySecurityNamespaces` to fetch names/display names and copy them exactly (case-insensitive matching is recommended but display text can vary across versions/locales). +- If ACL changes don't take effect as expected, inspect effective allow/deny values using `QueryAccessControlLists` with `includeExtendedInfo=true` and examine `ExtendedInfo` values such as `EffectiveAllow` and `EffectiveDeny`. + +## References + +- Azure DevOps Learn: Security overview and namespaces — https://learn.microsoft.com/azure/devops/organizations/security +- Azure DevOps REST: security namespace and ACL APIs (see SDK or REST API docs) +- Vendored SDK: `vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/security/client.go` + + +## Security namespace structure (detailed) + +A security namespace is a data structure the server uses to describe a family of permissions for a resource type. Important fields typically include: + +- Id (UUID): unique identifier for the namespace. +- Name / DisplayName: human-readable namespace name (e.g., "Git Repositories"). +- Actions: an array of action definitions where each action has: + - Bit: integer value representing the action as a single bit in a permission mask (e.g., 1, 2, 4, 8). These bits are powers of two so they can be combined with bitwise OR. + - Name: canonical name used by APIs/clients (e.g., `Read`, `Contribute`). + - DisplayName: user-friendly label shown in UI. +- Scopes / Hierarchy: some namespaces are hierarchical (for example, project-scoped tokens that have child tokens). For hierarchical namespaces, ACLs can be queried recursively. + +Example (pseudo-JSON) of a namespace action definition: + +```json +{ + "Actions": [ + { "Bit": 1, "Name": "Read", "DisplayName": "Read" }, + { "Bit": 2, "Name": "Write", "DisplayName": "Write" }, + { "Bit": 4, "Name": "Contribute", "DisplayName": "Contribute" }, + { "Bit": 8, "Name": "ManagePermissions", "DisplayName": "Manage permissions" } + ] +} +``` + +### What are security bits? + +Security bits are integer values where a single bit represents one action. They are typically powers of two so that multiple actions can be represented in a single integer using bitwise OR. For example: + +- `Read` → bit 1 (0x1) +- `Write` → bit 2 (0x2) +- `Contribute` → bit 4 (0x4) + +To represent `Read` + `Contribute`, compute `0x1 | 0x4 = 0x5`. + +### How ACEs are constructed from namespaces, bits, and a subject + +An ACE ties an identity (subject) to a set of allowed and denied bits for a specific token in a namespace. Construction steps: + +1. Identify the namespace and token that represents the resource to protect. +2. Resolve the subject into an ACL descriptor (see below). +3. Choose which actions to allow/deny. Convert textual action names to their `Bit` values via the namespace Actions array. +4. Combine action bits using bitwise OR to produce `Allow` and `Deny` integers. +5. Create the ACE with `{ descriptor: "", allow: , deny: }` and submit with `SetAccessControlEntries`. + +Example (JSON) ACE for a user allowing Read and Contribute: + +```json +{ + "descriptor": "vssgp.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + "allow": 5, // 0x1 | 0x4 + "deny": 0 +} +``` + +### How are ACE tokens generated? + +The `token` used by an ACL identifies which resource instance the ACL applies to. Token formats are namespace-specific. For example: + +- For project-level permissions the token might be `/projects/{projectId}` or simply `/` for the root of a namespace. +- For repository-level permissions a token might reference a repository ID or path. + +Token generation rules are defined by the namespace; the server accepts the token string and associates an ACL with it. To determine the correct token pattern for a namespace, consult the namespace documentation or call the API that manages that resource (for example, the Repos API returns repository IDs you can embed in tokens). The `azdo` CLI's `security permission list` and `show` commands can display tokens returned by the API so you can inspect existing ACLs and learn token formats. + +Practical tip: when in doubt, list ACLs for a namespace (without a token filter) to see commonly used token values in your organization. + +### Additional reading & references + +- QuerySecurityNamespaces (REST API) — discover names and bits for actions +- SetAccessControlEntries (REST API) — create/update ACEs for a token +- QueryAccessControlLists (REST API) — inspect ACLs and ACEs with extended info + +## Authoritative references and REST endpoints + +This appendix contains direct references to Microsoft Learn pages and the REST API patterns you can use to operate on namespaces, ACLs, and ACEs. + +- REST API pattern: Use the Azure DevOps REST API base pattern (include api-version): + - GET https://dev.azure.com/{organization}/_apis/security/namespaces/{namespaceId}?api-version=7.1-preview.1 + - GET https://dev.azure.com/{organization}/_apis/security/ACLs?securityNamespaceId={namespaceId}&token={token}&api-version=7.1-preview.1 + - POST https://dev.azure.com/{organization}/_apis/security/ACLs/Entries?securityNamespaceId={namespaceId}&api-version=7.1-preview.1 + + Note: the official REST docs are on Microsoft Learn; the exact endpoint path and query parameter names vary by API version — prefer using an SDK client (e.g., the vendored Go client) which marshals requests correctly. + +- Microsoft Learn pages consulted: + - Security overview & namespaces: https://learn.microsoft.com/en-us/azure/devops/organizations/security/about-security-identity + - Security namespace & permission reference: https://learn.microsoft.com/en-us/azure/devops/organizations/security/namespace-reference + - REST API guidance and samples: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/rest/samples + - TFSSecurity command reference (historical): https://learn.microsoft.com/en-us/azure/devops/server/command-line/tfssecurity-cmd + - General ACE/ACL concepts (Windows security): https://learn.microsoft.com/en-us/windows/win32/secauthz/access-control-entries + +## Concrete examples + +1) Discover actions for a namespace (pseudo-HTTP): + +GET https://dev.azure.com/myorg/_apis/security/namespaces/{namespaceId}?api-version=7.1-preview.1 + +Response snippet contains: + +```json +{ + "id": "{namespaceId}", + "displayName": "Git Repositories", + "actions": [ + { "bit": 1, "name": "Read", "displayName": "Read" }, + { "bit": 2, "name": "Write", "displayName": "Write" }, + { "bit": 4, "name": "Contribute", "displayName": "Contribute" } + ] +} +``` + +2) Inspect ACLs for a token (pseudo-HTTP): + +GET https://dev.azure.com/myorg/_apis/security/ACLs?securityNamespaceId={namespaceId}&token={token}&includeExtendedInfo=true&api-version=7.1-preview.1 + +Response contains ACL(s) with `acesDictionary` keyed by descriptor; each ACE includes `allow`, `deny`, and `extendedInfo` (effective/inherited values). + +3) Create or update ACEs (pseudo-HTTP body for SetAccessControlEntries): + +POST https://dev.azure.com/myorg/_apis/security/ACLs/Entries?securityNamespaceId={namespaceId}&api-version=7.1-preview.1 + +JSON body: + +```json +{ + "token": "{token}", + "aces": [ + { "descriptor": "{descriptor}", "allow": 5, "deny": 0 } + ], + "merge": true +} +``` + +If the specified descriptor does not exist in the ACL, the server will create that ACE. If it exists and `merge` is true, allow/deny are merged; otherwise the ACE is replaced. + +## Notes on tokens and token formation + +- There is no single global token format; tokens are defined by each namespace. Common tokens: + - Root token `/` meaning namespace root + - Project token such as `/projects/{projectId}` + - Resource-specific tokens that include resource IDs (e.g., repository IDs) +- To learn tokens used in your org, list ACLs for a namespace and inspect `token` values returned by `QueryAccessControlLists`. + +## Small warnings and handy links + +- Warning about API versions: the exact REST endpoint paths, query parameter names, and request body shapes for security operations can vary across API versions (e.g., preview vs stable). When automating or scripting, prefer using the official client libraries (SDKs) which handle marshalling and API-versioning for you. If you call the REST API directly, explicitly include `api-version` and verify the expected request format for that version. + +- Identity resolution (how to get the ACE descriptor): + - Resolve the graph descriptor for a subject: `Extensions.ResolveMemberDescriptor` (or use the CLI helper that does this). See the Extensions API docs in the Azure DevOps REST reference. + - Convert the graph descriptor to the ACL-style identity descriptor: `Identity.ReadIdentities` — use the returned identity's `Descriptor` field in ACEs. + +## Quick checklist (direct msdocs links) + +- Security overview & namespaces: https://learn.microsoft.com/en-us/azure/devops/organizations/security/about-security-identity +- Security namespace & permission reference: https://learn.microsoft.com/en-us/azure/devops/organizations/security/namespace-reference +- Query security namespaces (to discover Actions): see the Azure DevOps REST API reference and SDK `QuerySecurityNamespaces` method (browse: https://learn.microsoft.com/rest/api/azure/devops/) +- Query access control lists: check `QueryAccessControlLists` in the REST API reference / SDK +- Set access control entries: check `SetAccessControlEntries` in the REST API reference / SDK +- Extensions.ResolveMemberDescriptor: check the Extensions API in the REST reference +- Identity.ReadIdentities: check the Identity API in the REST reference + +(Use the Azure DevOps REST API index at https://learn.microsoft.com/rest/api/azure/devops/ to find the exact pages for the API version you need.) + +## Using the `azdo` CLI to work with namespaces and ACEs + +This repository provides `azdo` commands to inspect namespaces, list and show ACLs/ACEs, and update permissions. Below are common workflows using the CLI you now have in this repository. + +1. Discover available namespaces + + - List namespaces (shows namespace id and display name): + + `azdo security permission namespace list` + + - Show a single namespace (includes action definitions you can use by name): + + `azdo security permission namespace show --namespace-id ` + + Example output includes `actions` with `bit`, `name`, and `displayName` fields. Use those `name`/`displayName` values when supplying textual permission names to other commands. + +2. Inspect ACLs and ACEs + + - List ACEs for a namespace (optionally filter by token or subject): + + `azdo security permission list [ORGANIZATION[/PROJECT/]SUBJECT] --namespace-id [--token ] [--recurse]` + + Examples: + + - List all ACEs for a namespace using your default organization: + + `azdo security permission list --namespace-id 5a27515b-ccd7-42c9-84f1-54c998f03866` + + - List ACEs for a specific subject (email or group descriptor): + + `azdo security permission list fabrikam/contoso@example.com --namespace-id 5a27515b-...` + + - Show permissions for a single subject on a token: + + `azdo security permission show ORGANIZATION/SUBJECT --namespace-id --token ` + + This returns explicit and effective allow/deny action lists for the subject. + +3. Add or update an ACE (create if absent) + + - Use `azdo security permission update` (implemented in this repo) to add or update ACEs for a subject. `--allow-bit` and `--deny-bit` accept numeric or textual action names (from the namespace actions). + + Examples: + - Allow a textual action for a subject: + + `azdo security permission update fabrikam/contoso@example.com --namespace-id --token /projects/123 --allow-bit Read` + + - Allow multiple actions (names or numeric values): + + `azdo security permission update fabrikam/contoso@example.com --namespace-id --token /projects/123 --allow-bit Read --allow-bit Contribute --allow-bit 0x4` + + - Deny a numeric bit and merge with existing ACEs: + + `azdo security permission update fabrikam/contoso@example.com --namespace-id --token /projects/123 --deny-bit 8 --merge` + +Notes: + +- `azdo security permission update` will resolve the subject you provide (via Extensions + Identity APIs) into the ACL descriptor used in ACEs, and then call the security API to create or update the ACE. If the ACE did not exist it will be created. +- To avoid surprising replacements of existing ACEs, use `--merge` when you want to add bits without replacing existing allow/deny masks. + +1) Helpful quick workflow + +- Find a namespace ID for the resource you care about: `azdo security permission namespace list`. +- Inspect the actions for that namespace: `azdo security permission namespace show --namespace-id `. +- Inspect existing ACLs/tokens to learn token formats: `azdo security permission list --namespace-id `. +- Add or update an ACE: `azdo security permission update ORG/subject --namespace-id --token --allow-bit Read --merge`. + +If you want, I can add these examples to the CLI `--help` output or the generated docs (via `make docs`) so they appear in the command reference pages. + + +## Git repository token pattern (example) + +For the `repoV2` namespace tokens in this repository the pattern is: + +- `repoV2` — permissions for ALL Git repositories across ALL projects. +- `repoV2/{projectId}` — permissions for all Git repositories in a specific Azure DevOps project. +- `repoV2/{projectId}/{repoId}` — permissions for a single repository in that project. + +This hierarchy implies inheritance: project-level ACLs apply to repositories in that project unless a repo-level ACL overrides or refines them. + + +Example tree from your tokens (condensed): + +```text +repoV2 +├─ repoV2/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd +│ ├─ repoV2/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd/f4bc1443-0fd3-42fb-8ae0-2b6740994c52 +│ └─ (other repoIds under project f6ad111f...) +├─ repoV2/040a2515-b6d4-4b53-8749-fa8b649ce73a +│ └─ repoV2/040a2515-.../75ff1ff1-... +├─ repoV2/12ee2112-bbe9-4b70-9fdb-6592ad84098a +│ └─ repoV2/12ee2112-.../5983a302-... +└─ (additional projects and their repo children...) +``` + +Use `azdo security permission list --namespace-id --recurse` to enumerate tokens and build this tree programmatically. + + +Tip: Determine token formats by listing ACLs + +The canonical way to discover token formats for a namespace in your organization is to list the ACLs (which show token values) for that namespace. Using this repository's CLI you can run: + + azdo security permission list --namespace-id + +This calls the same logic implemented in `internal/cmd/security/permission/list/list.go` and will return ACL entries with `token` values you can inspect to learn the namespace's token patterns (including hierarchical tokens like `repoV2/{projectId}/{repoId}`). + + + +## Token structures observed per namespace + +### Namespace 58450c49-b02d-465a-ab12-59ae512d6531 (Analytics) +- Observed token prefixes: $ +- Token depth counts: + - depth 2: 5 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $/040a2515-b6d4-4b53-8749-fa8b649ce73a + - $/696416ee-f7ff-4ee3-934a-979b00dce74f + - $/70e9c41e-68af-4300-baa3-4eee0f48b17e + - $/964e3180-c2c0-4e82-80da-b3491fd6ed81 + - $/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd + + +### Namespace d34d3680-dfe5-4cc6-a949-7d9c68f73cba (AnalyticsViews) +- Observed token prefixes: $ +- Token depth counts: + - depth 2: 1 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $/Shared + + +### Namespace 62a7ad6b-8b8d-426b-ba10-76a7090e94d5 (PipelineCachePrivileges) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 7c7d32f7-0e86-4cd6-892e-b35dbba870bd (ReleaseManagement) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace c788c23e-1b46-4162-8f5e-d7585343b5de (ReleaseManagement) +- Observed token prefixes: 696416ee-f7ff-4ee3-934a-979b00dce74f, 70e9c41e-68af-4300-baa3-4eee0f48b17e, 964e3180-c2c0-4e82-80da-b3491fd6ed81 +- Token depth counts: + - depth 1: 3 tokens + + Typical interpretations: + + Sample tokens: + - 696416ee-f7ff-4ee3-934a-979b00dce74f + - 70e9c41e-68af-4300-baa3-4eee0f48b17e + - 964e3180-c2c0-4e82-80da-b3491fd6ed81 + + +### Namespace a6cc6381-a1ca-4b36-b3c1-4e65211e82b6 (AuditLog) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 445d2788-c5fb-4132-bbef-09c4045ad93f (WorkItemTrackingAdministration) +- Observed token prefixes: WorkItemTrackingPrivileges +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - WorkItemTrackingPrivileges + + +### Namespace 2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 (Git Repositories) +- Observed token prefixes: repoV2 +- Token depth counts: + - depth 1: 1 tokens + - depth 2: 15 tokens + - depth 3: 34 tokens + + Typical interpretations: + - `repoV2` tokens are for Git Repositories. Common pattern: + - `repoV2` → namespace root (all repos across org) + - `repoV2/{projectId}` → all repos in project {projectId} + - `repoV2/{projectId}/{repoId}` → repo {repoId} in project {projectId} + + Sample tokens: + - repoV2 + - repoV2/040a2515-b6d4-4b53-8749-fa8b649ce73a + - repoV2/040a2515-b6d4-4b53-8749-fa8b649ce73a/75ff1ff1-bf20-4399-8ad8-739c2b80ddfe + - repoV2/12ee2112-bbe9-4b70-9fdb-6592ad84098a + - repoV2/12ee2112-bbe9-4b70-9fdb-6592ad84098a/5983a302-019e-4e28-9f63-204c5bc6a8b7 + - repoV2/1b39554e-193a-47b2-bae7-bba1e5697ace + - repoV2/1b39554e-193a-47b2-bae7-bba1e5697ace/c8159776-866a-41b1-a065-06ab9f24b6bd + - repoV2/58d7cbb5-9855-45e7-93d3-f08409cb272e + + +### Namespace 3c15a8b7-af1a-45c2-aa97-2cb97078332e (VersionControlItems2) +- Observed token prefixes: $ +- Token depth counts: + - depth 1: 1 tokens + - depth 2: 15 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $ + - $/040a2515-b6d4-4b53-8749-fa8b649ce73a + - $/12ee2112-bbe9-4b70-9fdb-6592ad84098a + - $/1b39554e-193a-47b2-bae7-bba1e5697ace + - $/58d7cbb5-9855-45e7-93d3-f08409cb272e + - $/5e02360f-bc08-49fa-b5f3-bab8a9d7a408 + - $/696416ee-f7ff-4ee3-934a-979b00dce74f + - $/70e9c41e-68af-4300-baa3-4eee0f48b17e + + +### Namespace 2bf24a2b-70ba-43d3-ad97-3d9e1f75622f (EventSubscriber) +- Observed token prefixes: $SUBSCRIBER, $SUBSCRIBER:c4a53307-36c3-4ee9-8cac-fbde83eafaed +- Token depth counts: + - depth 1: 2 tokens + + Typical interpretations: + + Sample tokens: + - $SUBSCRIBER + - $SUBSCRIBER:c4a53307-36c3-4ee9-8cac-fbde83eafaed + + +### Namespace 5a6cd233-6615-414d-9393-48dbb252bd23 (WorkItemTrackingProvision) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 49b48001-ca20-4adc-8111-5b60c903a50c (ServiceEndpoints) +- Observed token prefixes: endpoints +- Token depth counts: + - depth 2: 4 tokens + - depth 3: 116 tokens + + Typical interpretations: + + Sample tokens: + - endpoints/00000000-0000-0000-0000-000000000000/3bbdd3ed-6f9c-4dba-b086-c6010d935237 + - endpoints/00000000-0000-0000-0000-000000000000/51e80eef-3469-4387-95da-325b4a77c3a6 + - endpoints/00000000-0000-0000-0000-000000000000/ceac4ce4-6d29-43db-92e4-e5c82869c901 + - endpoints/040a2515-b6d4-4b53-8749-fa8b649ce73a + - endpoints/040a2515-b6d4-4b53-8749-fa8b649ce73a/a212c44c-4074-4fe0-bd2b-940a469435ee + - endpoints/696416ee-f7ff-4ee3-934a-979b00dce74f + - endpoints/696416ee-f7ff-4ee3-934a-979b00dce74f/0d2aa2a7-6d35-4c8c-94ed-bc53a47ea7b1 + - endpoints/696416ee-f7ff-4ee3-934a-979b00dce74f/a6f19241-6a01-408d-aaac-b52b63d347f1 + + +### Namespace cb594ebe-87dd-4fc9-ac2c-6a10a4c92046 (ServiceHooks) +- Observed token prefixes: PublisherSecurity +- Token depth counts: + - depth 1: 1 tokens + - depth 2: 15 tokens + + Typical interpretations: + + Sample tokens: + - PublisherSecurity + - PublisherSecurity/040a2515-b6d4-4b53-8749-fa8b649ce73a + - PublisherSecurity/12ee2112-bbe9-4b70-9fdb-6592ad84098a + - PublisherSecurity/1b39554e-193a-47b2-bae7-bba1e5697ace + - PublisherSecurity/58d7cbb5-9855-45e7-93d3-f08409cb272e + - PublisherSecurity/5e02360f-bc08-49fa-b5f3-bab8a9d7a408 + - PublisherSecurity/696416ee-f7ff-4ee3-934a-979b00dce74f + - PublisherSecurity/70e9c41e-68af-4300-baa3-4eee0f48b17e + + +### Namespace 3e65f728-f8bc-4ecd-8764-7e378b19bfa7 (Collection) +- Observed token prefixes: NAMESPACE +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - NAMESPACE + + +### Namespace cb4d56d2-e84b-457e-8845-81320a133fbb (Proxy) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace bed337f8-e5f3-4fb9-80da-81e17d06e7a8 (Plan) +- Observed token prefixes: Plan +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - Plan + + +### Namespace 2dab47f9-bd70-49ed-9bd5-8eb051e59c02 (Process) +- Observed token prefixes: $PROCESS, $PROCESS:adcc42ab-9882-485e-a3ed-7678f01f66bc:1a9e818f-c3c8-42c5-8352-1315c45fbd75, $PROCESS:adcc42ab-9882-485e-a3ed-7678f01f66bc:2bcee563-f99a-4adf-925e-ce3aab36d591, $PROCESS:adcc42ab-9882-485e-a3ed-7678f01f66bc:302ad5c4-05c1-474b-96f6-943b7657b667 +- Token depth counts: + - depth 1: 4 tokens + + Typical interpretations: + + Sample tokens: + - $PROCESS + - $PROCESS:adcc42ab-9882-485e-a3ed-7678f01f66bc:1a9e818f-c3c8-42c5-8352-1315c45fbd75 + - $PROCESS:adcc42ab-9882-485e-a3ed-7678f01f66bc:2bcee563-f99a-4adf-925e-ce3aab36d591 + - $PROCESS:adcc42ab-9882-485e-a3ed-7678f01f66bc:302ad5c4-05c1-474b-96f6-943b7657b667 + + +### Namespace 11238e09-49f2-40c7-94d0-8f0307204ce4 (AccountAdminSecurity) +- Observed token prefixes: +- Token depth counts: + - depth 2: 1 tokens + + Typical interpretations: + + Sample tokens: + - /Ownership + + +### Namespace b7e84409-6553-448a-bbb2-af228e07cbeb (Library) +- Observed token prefixes: Library +- Token depth counts: + - depth 2: 4 tokens + - depth 4: 16 tokens + + Typical interpretations: + + Sample tokens: + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f/VariableGroup/82 + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f/VariableGroup/83 + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f/VariableGroup/84 + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f/VariableGroup/85 + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f/VariableGroup/86 + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f/VariableGroup/87 + - Library/696416ee-f7ff-4ee3-934a-979b00dce74f/VariableGroup/90 + + +### Namespace 83d4c2e6-e57d-4d6e-892b-b87222b7ad20 (Environment) +- Observed token prefixes: Environments +- Token depth counts: + - depth 2: 13 tokens + - depth 3: 8 tokens + + Typical interpretations: + + Sample tokens: + - Environments/040a2515-b6d4-4b53-8749-fa8b649ce73a + - Environments/1 + - Environments/2 + - Environments/3 + - Environments/4 + - Environments/5 + - Environments/6 + - Environments/696416ee-f7ff-4ee3-934a-979b00dce74f + + +### Namespace 58b176e7-3411-457a-89d0-c6d0ccb3c52b (EventSubscription) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 83e28ad4-2d72-4ceb-97b0-c7726d5502c3 (CSS) +- Observed token prefixes: vstfs: +- Token depth counts: + - depth 6: 15 tokens + - depth 11: 6 tokens + - depth 16: 3 tokens + - depth 21: 1 tokens + + Typical interpretations: + + Sample tokens: + - vstfs:///Classification/Node/01d16433-9dab-476d-bd7a-32818015d580 + - vstfs:///Classification/Node/0380fec0-4406-45d1-8357-19adfaee14a1 + - vstfs:///Classification/Node/0f0430d4-d25a-4d11-b4af-e4216fb3aa40 + - vstfs:///Classification/Node/0f0430d4-d25a-4d11-b4af-e4216fb3aa40:vstfs:///Classification/Node/10962b4d-983d-43ab-ad50-5a89fb771481 + - vstfs:///Classification/Node/0f0430d4-d25a-4d11-b4af-e4216fb3aa40:vstfs:///Classification/Node/10962b4d-983d-43ab-ad50-5a89fb771481:vstfs:///Classification/Node/317dd773-306f-44cc-a6e2-37831ccf5532 + - vstfs:///Classification/Node/0f0430d4-d25a-4d11-b4af-e4216fb3aa40:vstfs:///Classification/Node/10962b4d-983d-43ab-ad50-5a89fb771481:vstfs:///Classification/Node/9d068246-758c-471d-871c-dfb964be8fcc + - vstfs:///Classification/Node/0f0430d4-d25a-4d11-b4af-e4216fb3aa40:vstfs:///Classification/Node/10962b4d-983d-43ab-ad50-5a89fb771481:vstfs:///Classification/Node/a2ce7979-4d64-4db9-b576-f3a477c21eba + - vstfs:///Classification/Node/0f0430d4-d25a-4d11-b4af-e4216fb3aa40:vstfs:///Classification/Node/10962b4d-983d-43ab-ad50-5a89fb771481:vstfs:///Classification/Node/a2ce7979-4d64-4db9-b576-f3a477c21eba:vstfs:///Classification/Node/eec92af5-1d70-402b-ba78-3b517131af56 + + +### Namespace 9e4894c3-ff9a-4eac-8a85-ce11cafdc6f1 (TeamLabSecurity) +- Observed token prefixes: $ +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $ + + +### Namespace fc5b7b85-5d6b-41eb-8534-e128cb10eb67 (ProjectAnalysisLanguageMetrics) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace bb50f182-8e5e-40b8-bc21-e8752a1e7ae2 (Tagging) +- Observed token prefixes: , Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-1, Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-2 +- Token depth counts: + - depth 1: 2 tokens + - depth 2: 15 tokens + + Typical interpretations: + + Sample tokens: + - /040a2515-b6d4-4b53-8749-fa8b649ce73a + - /12ee2112-bbe9-4b70-9fdb-6592ad84098a + - /1b39554e-193a-47b2-bae7-bba1e5697ace + - /58d7cbb5-9855-45e7-93d3-f08409cb272e + - /5e02360f-bc08-49fa-b5f3-bab8a9d7a408 + - /696416ee-f7ff-4ee3-934a-979b00dce74f + - /70e9c41e-68af-4300-baa3-4eee0f48b17e + - /964e3180-c2c0-4e82-80da-b3491fd6ed81 + + +### Namespace f6a4de49-dbe2-4704-86dc-f8ec1a294436 (MetaTask) +- Observed token prefixes: 696416ee-f7ff-4ee3-934a-979b00dce74f, 70e9c41e-68af-4300-baa3-4eee0f48b17e, 964e3180-c2c0-4e82-80da-b3491fd6ed81, f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd +- Token depth counts: + - depth 1: 4 tokens + + Typical interpretations: + + Sample tokens: + - 696416ee-f7ff-4ee3-934a-979b00dce74f + - 70e9c41e-68af-4300-baa3-4eee0f48b17e + - 964e3180-c2c0-4e82-80da-b3491fd6ed81 + - f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd + + +### Namespace bf7bfa03-b2b7-47db-8113-fa2e002cc5b1 (Iteration) +- Observed token prefixes: vstfs: +- Token depth counts: + - depth 6: 15 tokens + - depth 11: 45 tokens + - depth 16: 1 tokens + + Typical interpretations: + + Sample tokens: + - vstfs:///Classification/Node/18c76992-93fa-4eb2-aac0-0abc0be212d6 + - vstfs:///Classification/Node/18c76992-93fa-4eb2-aac0-0abc0be212d6:vstfs:///Classification/Node/12a070b9-73dd-454f-9c79-eb92ed7a7630 + - vstfs:///Classification/Node/2d667e7c-3682-4046-bfe3-c70bd6fe91c4 + - vstfs:///Classification/Node/2d667e7c-3682-4046-bfe3-c70bd6fe91c4:vstfs:///Classification/Node/4565dbc2-000c-493d-8a28-b29c38dfde38 + - vstfs:///Classification/Node/2d667e7c-3682-4046-bfe3-c70bd6fe91c4:vstfs:///Classification/Node/88500b38-c8fb-486d-adf2-a73c002e0589 + - vstfs:///Classification/Node/2d667e7c-3682-4046-bfe3-c70bd6fe91c4:vstfs:///Classification/Node/b8de7cb2-e4b6-4956-b02d-2ebf48121938 + - vstfs:///Classification/Node/3343188c-c408-446c-8abb-64e645a7315d + - vstfs:///Classification/Node/3343188c-c408-446c-8abb-64e645a7315d:vstfs:///Classification/Node/1c4a6872-2c53-451d-a3fc-af39ad54f0e7 + + +### Namespace 71356614-aad7-4757-8f2c-0fb3bff6f680 (WorkItemQueryFolders) +- Observed token prefixes: $ +- Token depth counts: + - depth 1: 1 tokens + - depth 2: 1 tokens + - depth 3: 4 tokens + - depth 4: 1 tokens + - depth 6: 1 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $ + - $/696416ee-f7ff-4ee3-934a-979b00dce74f + - $/696416ee-f7ff-4ee3-934a-979b00dce74f/8669ab9b-d21f-4fab-98d3-bee06ea54d3c + - $/70e9c41e-68af-4300-baa3-4eee0f48b17e/51bd99bb-9cca-471b-832f-b24cb0070624 + - $/70e9c41e-68af-4300-baa3-4eee0f48b17e/51bd99bb-9cca-471b-832f-b24cb0070624/f6caba3d-7909-442c-afc0-265b761e452f + - $/70e9c41e-68af-4300-baa3-4eee0f48b17e/51bd99bb-9cca-471b-832f-b24cb0070624/f6caba3d-7909-442c-afc0-265b761e452f/95bbde9a-8e50-4461-bf96-d7a8dc63ff92/6a16b459-8a21-4bda-a649-50564f7ff1a0 + - $/964e3180-c2c0-4e82-80da-b3491fd6ed81/bac9a8c5-7fbb-41be-abbd-082a28061336 + - $/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd/3046cff6-83c9-449a-a024-be38fbaac2e5 + + +### Namespace fa557b48-b5bf-458a-bb2b-1b680426fe8b (Favorites) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 4ae0db5d-8437-4ee8-a18b-1f6fb38bd34c (Registry) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace c2ee56c9-e8fa-4cdd-9d48-2c44f697a58e (Graph) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace dc02bf3d-cd48-46c3-8a41-345094ecc94b (ViewActivityPaneSecurity) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 2a887f97-db68-4b7c-9ae3-5cebd7add999 (Job) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 7cd317f2-adc6-4b6c-8d99-6074faeaf173 (EventPublish) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 73e71c45-d483-40d5-bdba-62fd076f7f87 (WorkItemTracking) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 4a9e8381-289a-4dfd-8460-69028eaa93b3 (StrongBox) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 1f4179b3-6bac-4d01-b421-71ea09171400 (Server) +- Observed token prefixes: FrameworkGlobalSecurity +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - FrameworkGlobalSecurity + + +### Namespace e06e1c24-e93d-4e4a-908a-7d951187b483 (TestManagement) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 6ec4592e-048c-434e-8e6c-8671753a8418 (SettingEntries) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 302acaca-b667-436d-a946-87133492041c (BuildAdministration) +- Observed token prefixes: BuildPrivileges +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - BuildPrivileges + + +### Namespace 2725d2bc-7520-4af4-b0e3-8d876494731f (Location) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 251e12d9-bea3-43a8-bfdb-901b98c0125e (Boards) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace f0003bce-5f45-4f93-a25d-90fc33fe3aa9 (OrganizationLevelData) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 83abde3a-4593-424e-b45f-9898af99034d (UtilizationPermissions) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace c0e7a722-1cad-4ae6-b340-a8467501e7ce (WorkItemsHub) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 0582eb05-c896-449a-b933-aa3d99e121d6 (WebPlatform) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 66312704-deb5-43f9-b51c-ab4ff5e351c3 (VersionControlPrivileges) +- Observed token prefixes: Global +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - Global + + +### Namespace 93bafc04-9075-403a-9367-b7164eac6b5c (Workspaces) +- Observed token prefixes: Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-1, Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-2 +- Token depth counts: + - depth 1: 2 tokens + + Typical interpretations: + + Sample tokens: + - Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-1 + - Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-2 + + +### Namespace 093cbb02-722b-4ad6-9f88-bc452043fa63 (CrossProjectWidgetView) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 35e35e8e-686d-4b01-aff6-c369d6e36ce0 (WorkItemTrackingConfiguration) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 0d140cae-8ac1-4f48-b6d1-c93ce0301a12 (Discussion Threads) +- Observed token prefixes: Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-1, Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-2, Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-3 +- Token depth counts: + - depth 1: 3 tokens + + Typical interpretations: + + Sample tokens: + - Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-1 + - Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-2 + - Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-3 + + +### Namespace 5ab15bc8-4ea1-d0f3-8344-cab8fe976877 (BoardsExternalIntegration) +- Observed token prefixes: $ +- Token depth counts: + - depth 2: 3 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $/696416ee-f7ff-4ee3-934a-979b00dce74f + - $/70e9c41e-68af-4300-baa3-4eee0f48b17e + - $/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd + + +### Namespace 7ffa7cf4-317c-4fea-8f1d-cfda50cfa956 (DataProvider) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 81c27cc8-7a9f-48ee-b63f-df1e1d0412dd (Social) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 9a82c708-bfbe-4f31-984c-e860c2196781 (Security) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace a60e0d84-c2f8-48e4-9c0c-f32da48d5fd1 (IdentityPicker) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 84cc1aa4-15bc-423d-90d9-f97c450fc729 (ServicingOrchestration) +- Observed token prefixes: No +- Token depth counts: + - depth 1: 1 tokens + + Typical interpretations: + + Sample tokens: + - No + + +### Namespace 33344d9c-fc72-4d6f-aba5-fa317101a7e9 (Build) +- Observed token prefixes: 040a2515-b6d4-4b53-8749-fa8b649ce73a, 12ee2112-bbe9-4b70-9fdb-6592ad84098a, 1b39554e-193a-47b2-bae7-bba1e5697ace, 58d7cbb5-9855-45e7-93d3-f08409cb272e, 5e02360f-bc08-49fa-b5f3-bab8a9d7a408, 696416ee-f7ff-4ee3-934a-979b00dce74f, 70e9c41e-68af-4300-baa3-4eee0f48b17e, 964e3180-c2c0-4e82-80da-b3491fd6ed81, 97608c11-9c22-4f3f-9146-12f49d6f720d, b0281b3b-0ccb-4f8a-8761-c361ef9d9394, c1c630e4-abe1-4eed-9f30-0836ca9de4e6, ceee037d-2265-4e95-95e8-e1dd4eef9145, ead948c5-7ca4-43a4-8f94-558de8b873b2, f4eb664f-797c-4cf8-aa86-2e44bfaa2c81, f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd +- Token depth counts: + - depth 1: 15 tokens + - depth 2: 1 tokens + + Typical interpretations: + + Sample tokens: + - 040a2515-b6d4-4b53-8749-fa8b649ce73a + - 12ee2112-bbe9-4b70-9fdb-6592ad84098a + - 1b39554e-193a-47b2-bae7-bba1e5697ace + - 58d7cbb5-9855-45e7-93d3-f08409cb272e + - 5e02360f-bc08-49fa-b5f3-bab8a9d7a408 + - 696416ee-f7ff-4ee3-934a-979b00dce74f + - 696416ee-f7ff-4ee3-934a-979b00dce74f/237 + - 70e9c41e-68af-4300-baa3-4eee0f48b17e + + +### Namespace 8adf73b7-389a-4276-b638-fe1653f7efc7 (DashboardsPrivileges) +- Observed token prefixes: $ +- Token depth counts: + - depth 1: 1 tokens + - depth 3: 3 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $ + - $/964e3180-c2c0-4e82-80da-b3491fd6ed81/00000000-0000-0000-0000-000000000000 + - $/964e3180-c2c0-4e82-80da-b3491fd6ed81/b242d77b-d07c-4a5e-a9d8-3b29ef658448 + - $/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd/00000000-0000-0000-0000-000000000000 + + +### Namespace 52d39943-cb85-4d7f-8fa8-c6baac873819 (Project) +- Observed token prefixes: $PROJECT, $PROJECT:vstfs: +- Token depth counts: + - depth 1: 1 tokens + - depth 6: 15 tokens + + Typical interpretations: + + Sample tokens: + - $PROJECT + - $PROJECT:vstfs:///Classification/TeamProject/040a2515-b6d4-4b53-8749-fa8b649ce73a + - $PROJECT:vstfs:///Classification/TeamProject/12ee2112-bbe9-4b70-9fdb-6592ad84098a + - $PROJECT:vstfs:///Classification/TeamProject/1b39554e-193a-47b2-bae7-bba1e5697ace + - $PROJECT:vstfs:///Classification/TeamProject/58d7cbb5-9855-45e7-93d3-f08409cb272e + - $PROJECT:vstfs:///Classification/TeamProject/5e02360f-bc08-49fa-b5f3-bab8a9d7a408 + - $PROJECT:vstfs:///Classification/TeamProject/696416ee-f7ff-4ee3-934a-979b00dce74f + - $PROJECT:vstfs:///Classification/TeamProject/70e9c41e-68af-4300-baa3-4eee0f48b17e + + +### Namespace a39371cf-0841-4c16-bbd3-276e341bc052 (VersionControlItems) +- Observed token prefixes: $ +- Token depth counts: + - depth 1: 1 tokens + - depth 2: 15 tokens + + Typical interpretations: + - Tokens prefixed with `$` often indicate collection/project-scoped items (historical TFVC or generic tokens). Example: `$/`. + + Sample tokens: + - $ + - $/AzDO + - $/D12ee2112-bbe9-4b70-9fdb-6592ad84098a + - $/D1b39554e-193a-47b2-bae7-bba1e5697ace + - $/D58d7cbb5-9855-45e7-93d3-f08409cb272e + - $/D5e02360f-bc08-49fa-b5f3-bab8a9d7a408 + - $/D97608c11-9c22-4f3f-9146-12f49d6f720d + - $/Db0281b3b-0ccb-4f8a-8761-c361ef9d9394 + + + +## Authoritative reference and namespace examples + +The Microsoft Learn article "Security namespace and permission reference" is the canonical source for namespace IDs, token formats and whether a namespace is hierarchical or flat. Key points from that page (https://learn.microsoft.com/en-us/azure/devops/organizations/security/namespace-reference): + +- Namespaces are either hierarchical or flat; hierarchical namespaces support parent→child tokens and inheritance. +- Tokens are namespace-specific and case-insensitive; separators (commonly `/`) are used when path parts are variable-length. +- The page includes canonical token examples for many namespaces (Git Repositories, Build, Dashboards, etc.). + +### Small canonical mapping + +| Namespace (msdocs ID) | Typical token format (canonical) | Notes / observed samples | +|---|---|---| +| Git Repositories (`repoV2`, ID: 2e9eb7ed-...) | `repoV2` (root), `repoV2/{projectId}`, `repoV2/{projectId}/{repoId}` | Matches observed tokens in `namespace-aces.txt` and msdocs example. Use project-level token to affect all repos in a project. +| Build (`33344d9c-...`) | `PROJECT_ID` or `PROJECT_ID/{definitionId}` | msdocs shows build defs as `PROJECT_ID/12` examples; `namespace-aces.txt` shows service identity entries tied to project IDs. +| Dashboards (`8adf73b7-...`) | `$/PROJECT_ID/Team_ID/Dashboard_ID` | msdocs documents `$` prefixes for some UI-managed tokens; `namespace-aces.txt` shows `$` tokens for project-scoped entries. +| Analytics (`58450c49-...`) | `$/Shared/PROJECT_ID` | msdocs gives `$/Shared/PROJECT_ID` example for Analytics views. +| Project (`52d39943-...`) | `$PROJECT:vstfs:///Classification/TeamProject/{projectId}` | msdocs documents `$PROJECT` style root tokens for project-level permissions. + +Notes: +- The exact namespace IDs are available in `namespaces.txt` and on msdocs; use `azdo security permission namespace list` or the msdocs page to map names ↔ UUIDs. +- Always validate by listing ACL tokens for a namespace in your org (see "Tip: Determine token formats by listing ACLs" in this doc). diff --git a/internal/azdo/extensions/extension.go b/internal/azdo/extensions/extension.go index 4dad5b41..31019688 100644 --- a/internal/azdo/extensions/extension.go +++ b/internal/azdo/extensions/extension.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/microsoft/azure-devops-go-api/azuredevops/v7" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" "github.com/tmeckel/azdo-cli/internal/types" ) @@ -22,8 +23,9 @@ type Client interface { // FindGroupsByDisplayName locates Azure DevOps security groups that match the provided display name, // optionally scoped to a project descriptor, and returns their full details. FindGroupsByDisplayName(ctx context.Context, displayName string, scopeDescriptor *string) ([]*graph.GraphGroup, error) - // ResolveMemberDescriptor resolves a member identifier (descriptor, email, or principal name) into a graph subject descriptor. - ResolveMemberDescriptor(ctx context.Context, member string) (*graph.GraphSubject, error) + // ResolveSubject resolves a member identifier (descriptor, email, or principal name) into a graph subject descriptor. + ResolveSubject(ctx context.Context, member string) (*graph.GraphSubject, error) + ResolveIdentity(ctx context.Context, member string) (*identity.Identity, error) } type extensionClient struct { diff --git a/internal/azdo/extensions/member_lookup.go b/internal/azdo/extensions/member_lookup.go index 473c0c99..802af9e4 100644 --- a/internal/azdo/extensions/member_lookup.go +++ b/internal/azdo/extensions/member_lookup.go @@ -5,20 +5,18 @@ import ( "errors" "fmt" "net/http" - "regexp" "strings" "github.com/microsoft/azure-devops-go-api/azuredevops/v7" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/tmeckel/azdo-cli/internal/azdo/util" "github.com/tmeckel/azdo-cli/internal/types" "go.uber.org/zap" ) -var descriptorPattern = regexp.MustCompile(`^[^@\s]+\.[^@\s]+$`) - -// ResolveMemberDescriptor resolves a member identifier (descriptor, email, or principal name) into a graph subject descriptor. -func (c *extensionClient) ResolveMemberDescriptor(ctx context.Context, member string) (*graph.GraphSubject, error) { +// ResolveSubject resolves a member identifier (descriptor, email, or principal name) into a graph subject descriptor. +func (c *extensionClient) ResolveSubject(ctx context.Context, member string) (*graph.GraphSubject, error) { member = strings.TrimSpace(member) if member == "" { return nil, fmt.Errorf("member must not be empty") @@ -29,7 +27,7 @@ func (c *extensionClient) ResolveMemberDescriptor(ctx context.Context, member st return nil, fmt.Errorf("failed to create graph client: %w", err) } - if isDescriptor(member) { + if util.IsDescriptor(member) { zap.L().Debug("attempting graph subject lookup for descriptor", zap.String("descriptor", member)) subject, err := lookupGraphSubject(ctx, graphClient, member) if err != nil { @@ -38,7 +36,13 @@ func (c *extensionClient) ResolveMemberDescriptor(ctx context.Context, member st if subject == nil { return nil, fmt.Errorf("descriptor %q was not found", member) } - zap.L().Debug("resolved member via graph descriptor lookup", zap.String("descriptor", member)) + zap.L().Debug("resolved member via graph descriptor lookup", + zap.String("descriptor", member), + zap.String("displayName", + types.GetValue(subject.DisplayName, "")), + zap.String("subjectKind", types.GetValue(subject.SubjectKind, "")), + zap.String("legacyDescriptor", types.GetValue(subject.LegacyDescriptor, "")), + ) return subject, nil } @@ -90,13 +94,29 @@ func (c *extensionClient) ResolveMemberDescriptor(ctx context.Context, member st }, nil } +func (c *extensionClient) ResolveIdentity(ctx context.Context, member string) (*identity.Identity, error) { + member = strings.TrimSpace(member) + if member == "" { + return nil, fmt.Errorf("member must not be empty") + } + + identityClient, err := identity.NewClient(ctx, c.conn) + if err != nil { + return nil, fmt.Errorf("failed to create identity client: %w", err) + } + + return resolveIdentity(ctx, identityClient, member) +} + func lookupGraphSubject(ctx context.Context, client graph.Client, descriptor string) (*graph.GraphSubject, error) { if strings.TrimSpace(descriptor) == "" { return nil, nil } keys := []graph.GraphSubjectLookupKey{ - {Descriptor: types.ToPtr(descriptor)}, + { + Descriptor: types.ToPtr(descriptor), + }, } subjectLookup := graph.GraphSubjectLookup{ LookupKeys: &keys, @@ -139,14 +159,6 @@ func lookupGraphSubject(ctx context.Context, client graph.Client, descriptor str return nil, nil } -func isDescriptor(value string) bool { - value = strings.TrimSpace(value) - if value == "" { - return false - } - return descriptorPattern.MatchString(value) -} - func determineIdentitySearchFilters(member string) []string { member = strings.TrimSpace(member) memberLower := strings.ToLower(member) @@ -191,15 +203,58 @@ func memberSubjectKind(identity identity.Identity) string { } func resolveIdentity(ctx context.Context, client identity.Client, member string) (*identity.Identity, error) { + if util.IsSecurityIdentifier(member) { + zap.L().Debug("member is a SID", zap.String("member", member)) + descriptor := member + if !strings.Contains(member, ";") { + descriptor = "Microsoft.TeamFoundation.Identity;" + member + } + identities, err := client.ReadIdentities(ctx, identity.ReadIdentitiesArgs{ + Descriptors: &descriptor, + QueryMembership: &identity.QueryMembershipValues.None, + }) + if err != nil { + return nil, fmt.Errorf("failed to resolve member %q: %w", member, err) + } + if identities == nil || len(*identities) == 0 { + return nil, fmt.Errorf("identity %q not found", member) + } + if len(*identities) > 1 { + return nil, fmt.Errorf("multiple identities found for %q; specify a more specific identifier", member) + } + + return &(*identities)[0], nil + } + + if util.IsDescriptor(member) { + zap.L().Debug("member is a subject descriptor", zap.String("member", member)) + identities, err := client.ReadIdentities(ctx, identity.ReadIdentitiesArgs{ + SubjectDescriptors: &member, + QueryMembership: &identity.QueryMembershipValues.None, + }) + if err != nil { + return nil, fmt.Errorf("failed to resolve member %q: %w", member, err) + } + if identities == nil || len(*identities) == 0 { + return nil, fmt.Errorf("identity %q not found", member) + } + if len(*identities) > 1 { + return nil, fmt.Errorf("multiple identities found for %q; specify a more specific identifier", member) + } + + return &(*identities)[0], nil + } + filters := determineIdentitySearchFilters(member) for _, filter := range filters { localFilter := filter zap.L().Debug("resolving member via identity search", zap.String("filter", localFilter), zap.String("value", member)) identities, err := client.ReadIdentities(ctx, identity.ReadIdentitiesArgs{ - SearchFilter: &localFilter, - FilterValue: &member, - QueryMembership: &identity.QueryMembershipValues.None, + SearchFilter: &localFilter, + FilterValue: &member, + QueryMembership: &identity.QueryMembershipValues.None, + IncludeRestrictedVisibility: types.ToPtr(true), }) if err != nil { return nil, fmt.Errorf("failed to resolve member %q: %w", member, err) @@ -211,8 +266,7 @@ func resolveIdentity(ctx context.Context, client identity.Client, member string) return nil, fmt.Errorf("multiple identities found for %q; specify a more specific identifier", member) } - identityResult := (*identities)[0] - return &identityResult, nil + return &(*identities)[0], nil } return nil, nil diff --git a/internal/azdo/util/descriptors.go b/internal/azdo/util/descriptors.go new file mode 100644 index 00000000..0e4642c0 --- /dev/null +++ b/internal/azdo/util/descriptors.go @@ -0,0 +1,35 @@ +package util + +import ( + "regexp" + "strings" +) + +var ( + descriptorPattern = regexp.MustCompile(`^[a-zA-Z]+\.[a-zA-Z0-9-_]+$`) + sidPattern = regexp.MustCompile(`(?i)s-\d+-\d+(-\d+)+$`) +) + +// A descriptor is a string containing two elements, which are separated by a period '.' +// +// '.' +// +// The identifier is a base64 string with no padding: +// '=' removed +// '+' replaced by '-' +// '/' replaced by '_' +func IsDescriptor(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + return descriptorPattern.MatchString(value) +} + +func IsSecurityIdentifier(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + return sidPattern.MatchString(value) +} diff --git a/internal/azdo/util/descriptors_test.go b/internal/azdo/util/descriptors_test.go new file mode 100644 index 00000000..b97bdc87 --- /dev/null +++ b/internal/azdo/util/descriptors_test.go @@ -0,0 +1,47 @@ +package util + +import "testing" + +func TestIsSecurityIdentifier(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + { + name: "valid sid uppercase", + value: "S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-1", + want: true, + }, + { + name: "valid sid lowercase", + value: "s-1-5-21-123456789-123456789-123456789-1000", + want: true, + }, + { + name: "not sid descriptor with dot", + value: "vssgp.Uy0xLTktMTIz", + want: false, + }, + { + name: "empty string", + value: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsSecurityIdentifier(tt.value); got != tt.want { + t.Fatalf("IsSecurityIdentifier(%q) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} + +func TestIsDescriptorNotRecognizesSID(t *testing.T) { + sid := "S-1-5-21-123456789-123456789-123456789-1000" + if IsDescriptor(sid) { + t.Fatalf("expected SID %q to be treated as descriptor", sid) + } +} diff --git a/internal/cmd/security/group/membership/add/add.go b/internal/cmd/security/group/membership/add/add.go index 4d4fe991..8949d65e 100644 --- a/internal/cmd/security/group/membership/add/add.go +++ b/internal/cmd/security/group/membership/add/add.go @@ -134,7 +134,7 @@ func runAdd(ctx util.CmdContext, o *opts) error { return util.FlagErrorf("member value must not be empty") } - memberSubject, err := extensionsClient.ResolveMemberDescriptor(ctx.Context(), memberInput) + memberSubject, err := extensionsClient.ResolveSubject(ctx.Context(), memberInput) if err != nil { return err } diff --git a/internal/cmd/security/group/membership/remove/remove.go b/internal/cmd/security/group/membership/remove/remove.go index b51ea203..01da35ab 100644 --- a/internal/cmd/security/group/membership/remove/remove.go +++ b/internal/cmd/security/group/membership/remove/remove.go @@ -140,7 +140,7 @@ func runRemove(ctx util.CmdContext, o *opts) error { return util.FlagErrorf("member value must not be empty") } - memberSubject, err := extensionsClient.ResolveMemberDescriptor(ctx.Context(), memberInput) + memberSubject, err := extensionsClient.ResolveSubject(ctx.Context(), memberInput) if err != nil { return err } diff --git a/internal/cmd/security/permission/list/list.go b/internal/cmd/security/permission/list/list.go index dd9e455d..24bcc44c 100644 --- a/internal/cmd/security/permission/list/list.go +++ b/internal/cmd/security/permission/list/list.go @@ -6,7 +6,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/google/uuid" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" "github.com/spf13/cobra" "go.uber.org/zap" @@ -143,47 +142,17 @@ func runCommand(ctx util.CmdContext, o *opts) error { return err } - member, err := extensionsClient.ResolveMemberDescriptor(ctx.Context(), scope.Subject) + member, err := extensionsClient.ResolveIdentity(ctx.Context(), scope.Subject) if err != nil { return fmt.Errorf("failed to resolve subject %q: %w", scope.Subject, err) } - // The graph subject descriptor returned from ResolveMemberDescriptor may not - // be in the same form that the Security API expects for the `Descriptors` - // parameter. Resolve the identity via the Identity API and use the - // identity's `Descriptor` value (which matches ACL entries) when calling - // QueryAccessControlLists. This mirrors the approach used in the - // terraform-provider-azuredevops implementation. - - identityClient, ierr := ctx.ClientFactory().Identity(ctx.Context(), scope.Organization) - if ierr != nil { - return fmt.Errorf("failed to create identity client: %w", ierr) - } - - // Ask the Identity service for the identity matching the graph descriptor subj := strings.TrimSpace(types.GetValue(member.Descriptor, "")) if subj == "" { return fmt.Errorf("resolved subject descriptor is empty") } - sd := subj - idents, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{ - SubjectDescriptors: &sd, - }) - if err != nil { - return fmt.Errorf("failed to read identity information for %q: %w", subj, err) - } - if idents == nil || len(*idents) == 0 { - return fmt.Errorf("no identity returned for descriptor %q", subj) - } - - // Use the identity's Descriptor (this is the form used in ACLs) - identityDescriptor := strings.TrimSpace(types.GetValue((*idents)[0].Descriptor, "")) - if identityDescriptor == "" { - return fmt.Errorf("identity for %q did not contain a descriptor", subj) - } - zap.L().Sugar().Debugf("Resolved subject descriptor (acl)=%q", identityDescriptor) - requestArgs.Descriptors = types.ToPtr(identityDescriptor) + requestArgs.Descriptors = &subj } if strings.TrimSpace(o.token) != "" { @@ -194,14 +163,14 @@ func runCommand(ctx util.CmdContext, o *opts) error { requestArgs.Recurse = types.ToPtr(true) } - zap.L().Sugar().Debugf("Querying ACEs (token=%q recurse=%v subjectFilter=%v)", o.token, o.recurse, hasSubject) + zap.L().Sugar().Debugf("Querying ACEs (token=%q recurse=%v subjectFilter=%q)", o.token, o.recurse, scope.Subject) response, err := securityClient.QueryAccessControlLists(ctx.Context(), requestArgs) if err != nil { return fmt.Errorf("failed to query access control lists: %w", err) } - entries := transformResponse(response, requestArgs.Descriptors) + entries := transformResponse(response) ios.StopProgressIndicator() @@ -246,7 +215,7 @@ func runCommand(ctx util.CmdContext, o *opts) error { return table.Render() } -func transformResponse(response *[]security.AccessControlList, descriptor *string) []permissionEntry { +func transformResponse(response *[]security.AccessControlList) []permissionEntry { if response == nil { return nil } @@ -257,24 +226,12 @@ func transformResponse(response *[]security.AccessControlList, descriptor *strin continue } - if descriptor != nil && strings.TrimSpace(*descriptor) != "" { - ace, ok := (*acl.AcesDictionary)[*descriptor] - if !ok { - zap.L().Sugar().Debugf("Skipping ACL for token=%q without matching descriptor", types.GetValue(acl.Token, "")) - continue - } - entry := buildPermissionEntry(acl, ace, descriptor) - results = append(results, entry) - continue - } - for key, ace := range *acl.AcesDictionary { desc := key if ace.Descriptor != nil && strings.TrimSpace(*ace.Descriptor) != "" { desc = strings.TrimSpace(*ace.Descriptor) } - localDescriptor := desc - entry := buildPermissionEntry(acl, ace, &localDescriptor) + entry := buildPermissionEntry(acl, ace, &desc) results = append(results, entry) } } diff --git a/internal/cmd/security/permission/list/list_test.go b/internal/cmd/security/permission/list/list_test.go new file mode 100644 index 00000000..fca371d7 --- /dev/null +++ b/internal/cmd/security/permission/list/list_test.go @@ -0,0 +1,71 @@ +package list + +import ( + "context" + "fmt" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" + "github.com/stretchr/testify/require" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/mock/gomock" +) + +func TestList_UsesSubjectDescriptorWhenIdentityMissing(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + + mCmdCtx := mocks.NewMockCmdContext(ctrl) + mClientFactory := mocks.NewMockClientFactory(ctrl) + mExtensionsClient := mocks.NewMockAzDOExtension(ctrl) + mIdentityClient := mocks.NewMockIdentityClient(ctrl) + mSecurityClient := mocks.NewMockSecurityClient(ctrl) + + sid := "S-1-9-1551374245-1204400969-2402986413-2179408616-0-0-0-0-1" + namespaceID := "00000000-0000-0000-0000-000000000000" + + mCmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + mCmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + mCmdCtx.EXPECT().ClientFactory().Return(mClientFactory).AnyTimes() + + mClientFactory.EXPECT().Extensions(gomock.Any(), gomock.Any()).Return(mExtensionsClient, nil).AnyTimes() + mClientFactory.EXPECT().Identity(gomock.Any(), gomock.Any()).Return(mIdentityClient, nil).AnyTimes() + mClientFactory.EXPECT().Security(gomock.Any(), gomock.Any()).Return(mSecurityClient, nil).AnyTimes() + + // The implementation now calls ResolveIdentity; return an identity with Descriptor set. + ident := identity.Identity{ + Descriptor: types.ToPtr(sid), + } + mExtensionsClient.EXPECT().ResolveIdentity(gomock.Any(), sid).Return(&ident, nil) + + // Identity lookup is skipped when the resolved subject does not contain + // enough information to perform the lookup. The subject descriptor is + // used as a fallback, so no call to ReadIdentities is expected. + + mSecurityClient.EXPECT().QueryAccessControlLists(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, args security.QueryAccessControlListsArgs) (*[]security.AccessControlList, error) { + if args.Descriptors == nil { + return nil, fmt.Errorf("descriptors unexpectedly nil") + } + if got := types.GetValue(args.Descriptors, ""); got != sid { + return nil, fmt.Errorf("expected descriptor %q, got %q", sid, got) + } + result := []security.AccessControlList{} + return &result, nil + }, + ) + + o := &opts{ + rawTarget: fmt.Sprintf("org/%s", sid), + namespaceID: namespaceID, + } + + err := runCommand(mCmdCtx, o) + require.NoError(t, err) + require.Contains(t, out.String(), "No permissions found.") +} diff --git a/internal/cmd/security/permission/permission.go b/internal/cmd/security/permission/permission.go index 2f546740..d68b5c8c 100644 --- a/internal/cmd/security/permission/permission.go +++ b/internal/cmd/security/permission/permission.go @@ -5,6 +5,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/security/permission/list" "github.com/tmeckel/azdo-cli/internal/cmd/security/permission/namespace" "github.com/tmeckel/azdo-cli/internal/cmd/security/permission/show" + "github.com/tmeckel/azdo-cli/internal/cmd/security/permission/update" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -22,6 +23,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(namespace.NewCmd(ctx)) cmd.AddCommand(show.NewCmd(ctx)) + cmd.AddCommand(update.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/security/permission/shared/access_control.go b/internal/cmd/security/permission/shared/access_control.go new file mode 100644 index 00000000..96923045 --- /dev/null +++ b/internal/cmd/security/permission/shared/access_control.go @@ -0,0 +1,116 @@ +package shared + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" + "github.com/tmeckel/azdo-cli/internal/types" +) + +// CloneAccessControlEntry returns a deep copy of the provided ACE, including any nested +// extended information pointers. Callers receive a brand new struct so they can mutate the +// result without affecting the original value. +func CloneAccessControlEntry(src *security.AccessControlEntry) *security.AccessControlEntry { + if src == nil { + return nil + } + + clone := security.AccessControlEntry{} + + if src.Allow != nil { + v := *src.Allow + clone.Allow = &v + } + if src.Deny != nil { + v := *src.Deny + clone.Deny = &v + } + if src.Descriptor != nil { + v := strings.TrimSpace(*src.Descriptor) + clone.Descriptor = &v + } + if src.ExtendedInfo != nil { + clone.ExtendedInfo = CloneAceExtendedInformation(src.ExtendedInfo) + } + + return &clone +} + +// CloneAceExtendedInformation produces a safe, deep copy of the extended ACE information. +func CloneAceExtendedInformation(src *security.AceExtendedInformation) *security.AceExtendedInformation { + if src == nil { + return nil + } + + clone := security.AceExtendedInformation{} + + if src.EffectiveAllow != nil { + v := *src.EffectiveAllow + clone.EffectiveAllow = &v + } + if src.EffectiveDeny != nil { + v := *src.EffectiveDeny + clone.EffectiveDeny = &v + } + if src.InheritedAllow != nil { + v := *src.InheritedAllow + clone.InheritedAllow = &v + } + if src.InheritedDeny != nil { + v := *src.InheritedDeny + clone.InheritedDeny = &v + } + + return &clone +} + +// FindAccessControlEntry looks up a descriptor within the specified namespace/token and +// returns a cloned copy of the matching ACE when one exists. +func FindAccessControlEntry(ctx context.Context, client security.Client, namespaceID uuid.UUID, token, descriptor string) (*security.AccessControlEntry, error) { + if client == nil { + return nil, fmt.Errorf("security client is required") + } + + descriptor = strings.TrimSpace(descriptor) + if descriptor == "" { + return nil, fmt.Errorf("descriptor is required") + } + + token = strings.TrimSpace(token) + args := security.QueryAccessControlListsArgs{ + SecurityNamespaceId: &namespaceID, + Descriptors: &descriptor, + IncludeExtendedInfo: types.ToPtr(true), + } + + if token != "" { + tokenCopy := token + args.Token = &tokenCopy + } + + descCopy := descriptor + args.Descriptors = &descCopy + + acls, err := client.QueryAccessControlLists(ctx, args) + if err != nil { + return nil, err + } + if acls == nil { + return nil, nil + } + + for i := range *acls { + acl := (*acls)[i] + if acl.AcesDictionary == nil { + continue + } + for _, ace := range *acl.AcesDictionary { + return CloneAccessControlEntry(&ace), nil + } + } + + return nil, nil +} diff --git a/internal/cmd/security/permission/shared/target.go b/internal/cmd/security/permission/shared/target.go index 514714fe..e585b812 100644 --- a/internal/cmd/security/permission/shared/target.go +++ b/internal/cmd/security/permission/shared/target.go @@ -78,7 +78,8 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Scope: *scope, + Scope: *scope, + Subject: subject, }, nil default: return nil, util.FlagErrorf("invalid target %q", input) diff --git a/internal/cmd/security/permission/show/show.go b/internal/cmd/security/permission/show/show.go index 4e3ffdfb..d14403ef 100644 --- a/internal/cmd/security/permission/show/show.go +++ b/internal/cmd/security/permission/show/show.go @@ -110,7 +110,7 @@ func runCommand(ctx util.CmdContext, o *opts) error { hasSubject := scope.Subject != "" if !hasSubject { - return util.FlagErrorf("a subject is required") + return util.FlagErrorf("a subject is required; org: %q", scope.Organization) } if scope.Project != "" { @@ -137,7 +137,7 @@ func runCommand(ctx util.CmdContext, o *opts) error { return err } - member, err := extensionsClient.ResolveMemberDescriptor(ctx.Context(), scope.Subject) + member, err := extensionsClient.ResolveSubject(ctx.Context(), scope.Subject) if err != nil { return fmt.Errorf("failed to resolve subject %q: %w", scope.Subject, err) } @@ -158,12 +158,21 @@ func runCommand(ctx util.CmdContext, o *opts) error { if err != nil { return fmt.Errorf("failed to read identity information for %q: %w", subj, err) } - if idents == nil || len(*idents) == 0 { - return fmt.Errorf("no identity returned for descriptor %q", subj) + + descriptor := subj + if idents != nil && len(*idents) > 0 { + candidate := strings.TrimSpace(types.GetValue((*idents)[0].Descriptor, "")) + if candidate != "" { + descriptor = candidate + } else { + zap.L().Sugar().Debugf("Identity lookup returned empty descriptor for %q; falling back to subject descriptor", subj) + } + } else { + zap.L().Sugar().Debugf("Identity lookup returned no entries for %q; using subject descriptor directly", subj) } - descriptor := strings.TrimSpace(types.GetValue((*idents)[0].Descriptor, "")) + if descriptor == "" { - return fmt.Errorf("identity for %q did not contain a descriptor", subj) + return fmt.Errorf("unable to resolve descriptor for %q", subj) } zap.L().Sugar().Debugf("Resolved subject descriptor (acl)=%q", descriptor) @@ -182,7 +191,7 @@ func runCommand(ctx util.CmdContext, o *opts) error { return fmt.Errorf("failed to query access control lists: %w", err) } - entry := transformResponse(response, &descriptor, actionDefinitions) + entry := transformResponse(response, actionDefinitions) ios.StopProgressIndicator() @@ -220,8 +229,9 @@ func runCommand(ctx util.CmdContext, o *opts) error { return table.Render() } -func transformResponse(response *[]security.AccessControlList, descriptor *string, actions []security.ActionDefinition) *permissionEntry { +func transformResponse(response *[]security.AccessControlList, actions []security.ActionDefinition) *permissionEntry { if response == nil || len(*response) == 0 { + zap.L().Debug("array security.AccessControlList empty or nil") return nil } @@ -230,8 +240,12 @@ func transformResponse(response *[]security.AccessControlList, descriptor *strin continue } - if ace, ok := (*acl.AcesDictionary)[*descriptor]; ok { - entry := buildPermissionEntry(acl, ace, descriptor, actions) + for key, ace := range *acl.AcesDictionary { + desc := key + if ace.Descriptor != nil && strings.TrimSpace(*ace.Descriptor) != "" { + desc = strings.TrimSpace(*ace.Descriptor) + } + entry := buildPermissionEntry(acl, ace, &desc, actions) return &entry } } diff --git a/internal/cmd/security/permission/update/update.go b/internal/cmd/security/permission/update/update.go new file mode 100644 index 00000000..f3f700cc --- /dev/null +++ b/internal/cmd/security/permission/update/update.go @@ -0,0 +1,290 @@ +package update + +import ( + "fmt" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/security/permission/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type opts struct { + rawTarget string + namespaceID string + token string + allowBits []string + denyBits []string + merge bool + yes bool +} + +type AccessControlEntryUpdate struct { + Token string `json:"token"` + Merge bool `json:"merge"` + AccessControlEntries []security.AccessControlEntry `json:"accessControlEntries"` +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + o := &opts{} + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update or create permissions for a user or group.", + Long: heredoc.Doc(` + Update the permissions for a user or group on a specific securable resource (identified by a token) by assigning "allow" or "deny" permission bits. + + The --allow-bit and --deny-bit flags accept one or more permission values. Each value may be provided as: + - a hexadecimal bitmask (e.g. 0x4), + - a decimal bit value (e.g. 4), or + - a textual action name matching the namespace action (e.g. "Read", "Edit"). + + To discover the available actions (and their textual names) for a security namespace, use: + azdo security permission namespace show --namespace-id + + Accepted TARGET formats: + - ORGANIZATION/SUBJECT → target subject in org + - ORGANIZATION/PROJECT/SUBJECT → subject scoped to project + + Token hierarchy (Git repo namespace example): + - repoV2 → all repos across org + - repoV2/{projectId} → all repos in project + - repoV2/{projectId}/{repoId} → single repo in project + + + - ORGANIZATION/SUBJECT → target subject in org + - ORGANIZATION/PROJECT/SUBJECT → subject scoped to project + `), + Example: heredoc.Doc(` + # Allow the Read action (textual) for a user on a token + azdo security permission update fabrikam/contoso@example.com --namespace-id 71356614-aad7-4757-8f2c-0fb3bff6f680 --token '$/696416ee-f7ff-4ee3-934a-979b00dce74f' --allow-bit Read + + # Allow multiple actions by specifying --allow-bit multiple times (textual and numeric) + azdo security permission update fabrikam/contoso@example.com --namespace-id bf7bfa03-b2b7-47db-8113-fa2e002cc5b1 --token vstfs:///Classification/Node/18c76992-93fa-4eb2-aac0-0abc0be212d6 --allow-bit Read --allow-bit Contribute --allow-bit 0x4 + + # Allow multiple actions using a single comma-separated value (shells may need quoting) + azdo security permission update fabrikam/contoso@example.com --namespace-id 302acaca-b667-436d-a946-87133492041c --token BuildPrivileges --allow-bit "Read,Contribute,0x4" + + # Deny a numeric bit and merge with existing ACEs (merge will OR incoming bits with existing ACE) + azdo security permission update fabrikam/contoso@example.com --namespace-id 33344d9c-fc72-4d6f-aba5-fa317101a7e9 --token '696416ee-f7ff-4ee3-934a-979b00dce74f/237' --deny-bit 8 --merge + + # Use --yes to skip confirmation prompts + azdo security permission update fabrikam/contoso@example.com --namespace-id 8adf73b7-389a-4276-b638-fe1653f7efc7 --token '$/f6ad111f-42cb-4e2d-b22a-cd0bd6f5aebd/00000000-0000-0000-0000-000000000000' --allow-bit Read --yes + `), + Aliases: []string{ + "create", + "u", + "new", + }, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + o.rawTarget = args[0] + return runCommand(ctx, o) + }, + } + + cmd.Flags().StringVarP(&o.namespaceID, "namespace-id", "n", "", "ID of the security namespace to modify (required).") + cmd.Flags().StringVar(&o.token, "token", "", "Security token for the resource (required).") + cmd.Flags().StringSliceVar(&o.allowBits, "allow-bit", []string{}, "Permission bit or comma-separated bits to allow.") + cmd.Flags().StringSliceVar(&o.denyBits, "deny-bit", []string{}, "Permission bit or comma-separated bits to deny.") + cmd.Flags().BoolVarP(&o.yes, "yes", "y", false, "Do not prompt for confirmation.") + cmd.Flags().BoolVar(&o.merge, "merge", false, "Merge incoming ACEs with existing entries or replace the permissions. If provided without value true is implied.") + cmd.Flags().Lookup("merge").NoOptDefVal = "true" + + return cmd +} + +func runCommand(ctx util.CmdContext, o *opts) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if strings.TrimSpace(o.namespaceID) == "" { + return util.FlagErrorf("--namespace-id is required") + } + if strings.TrimSpace(o.token) == "" { + return util.FlagErrorf("--token is required") + } + + namespaceUUID, err := uuid.Parse(strings.TrimSpace(o.namespaceID)) + if err != nil { + return util.FlagErrorf("invalid namespace id %q: %v", o.namespaceID, err) + } + + scope, err := shared.ParseSubjectTarget(ctx, o.rawTarget) + if err != nil { + return err + } + + hasSubject := scope.Subject != "" + if !hasSubject { + return util.FlagErrorf("a subject is required") + } + + if scope.Project != "" { + if _, _, err := util.ResolveScopeDescriptor(ctx, scope.Organization, scope.Project); err != nil { + return err + } + } + + extensionsClient, err := ctx.ClientFactory().Extensions(ctx.Context(), scope.Organization) + if err != nil { + return err + } + + member, err := extensionsClient.ResolveIdentity(ctx.Context(), scope.Subject) + if err != nil { + return fmt.Errorf("failed to resolve identity %q: %w", scope.Subject, err) + } + if member.Descriptor == nil { + return fmt.Errorf("identity %q does not have a descriptor", member.Id.String()) + } + securityClient, err := ctx.ClientFactory().Security(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create security client: %w", err) + } + + // load namespace action definitions so we can accept textual action names + nsDetails, err := securityClient.QuerySecurityNamespaces(ctx.Context(), security.QuerySecurityNamespacesArgs{ + SecurityNamespaceId: &namespaceUUID, + }) + if err != nil { + return fmt.Errorf("failed to load namespace actions: %w", err) + } + actions := shared.ExtractNamespaceActions(nsDetails) + + // require at least one of the flags to be provided + if len(o.allowBits) == 0 && len(o.denyBits) == 0 { + return util.FlagErrorf("at least one of --allow-bit or --deny-bit must be provided") + } + + var allowVal *int + var denyVal *int + if len(o.allowBits) > 0 { + v, err := parseBits(actions, o.allowBits) + if err != nil { + return err + } + allowVal = &v + } + if len(o.denyBits) > 0 { + v, err := parseBits(actions, o.denyBits) + if err != nil { + return err + } + denyVal = &v + } + + ace := security.AccessControlEntry{ + Descriptor: member.Descriptor, + Allow: allowVal, + Deny: denyVal, + } + + container := AccessControlEntryUpdate{ + Token: strings.TrimSpace(o.token), + Merge: o.merge, + AccessControlEntries: []security.AccessControlEntry{ace}, + } + + zap.L().Sugar().Debugf("Setting ACE token=%q descriptor=%q allow=%v deny=%v merge=%v", o.token, *member.Descriptor, allowVal, denyVal, o.merge) + + _, err = securityClient.SetAccessControlEntries(ctx.Context(), security.SetAccessControlEntriesArgs{ + Container: container, + SecurityNamespaceId: &namespaceUUID, + }) + if err != nil { + return fmt.Errorf("failed to set access control entries: %w", err) + } + + ios.StopProgressIndicator() + fmt.Fprintln(ios.Out, "Permissions updated.") + return nil +} + +func parseBits(actions []security.ActionDefinition, parts []string) (int, error) { + var val int + + // Build a map for quick name->bit lookup (case-insensitive) and track all allowed bits. + nameMap := make(map[string]int) + var allowedMask int + for _, a := range actions { + bit := types.GetValue(a.Bit, 0) + if bit == 0 { + continue + } + + allowedMask |= bit + + if n := strings.TrimSpace(types.GetValue(a.Name, "")); n != "" { + nameMap[strings.ToLower(n)] = bit + } + if dn := strings.TrimSpace(types.GetValue(a.DisplayName, "")); dn != "" { + nameMap[strings.ToLower(dn)] = bit + } + nameMap[strings.ToLower(fmt.Sprintf("bit %d", bit))] = bit + } + + checkAllowed := func(bitVal int) error { + if bitVal == 0 { + return fmt.Errorf("permission bit value cannot be zero") + } + if allowedMask != 0 && bitVal&^allowedMask != 0 { + return fmt.Errorf("permission bit value %d is not defined for this namespace", bitVal) + } + return nil + } + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // numeric hex: 0x prefix + if strings.HasPrefix(p, "0x") || strings.HasPrefix(p, "0X") { + v, err := strconv.ParseInt(p[2:], 16, 32) + if err != nil { + return 0, fmt.Errorf("invalid bit value %q: %w", p, err) + } + candidate := int(v) + if err := checkAllowed(candidate); err != nil { + return 0, err + } + val |= candidate + continue + } + + // numeric decimal + if d, err := strconv.ParseInt(p, 10, 32); err == nil { + candidate := int(d) + if err := checkAllowed(candidate); err != nil { + return 0, err + } + val |= candidate + continue + } + + // textual name match (case-insensitive) + l := strings.ToLower(p) + if bit, ok := nameMap[l]; ok { + val |= bit + continue + } + + return 0, fmt.Errorf("unrecognized permission token %q", p) + } + + return val, nil +} diff --git a/internal/cmd/security/permission/update/update_acc_test.go b/internal/cmd/security/permission/update/update_acc_test.go new file mode 100644 index 00000000..4af6ef5a --- /dev/null +++ b/internal/cmd/security/permission/update/update_acc_test.go @@ -0,0 +1,134 @@ +package update + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" + + "github.com/tmeckel/azdo-cli/internal/cmd/security/permission/shared" + inttest "github.com/tmeckel/azdo-cli/internal/test" +) + +func TestAccUpdatePermission(t *testing.T) { + // static values chosen for acceptance run — these are well-known namespace/token patterns + // repoV2 namespace id (example from docs/data in repo). Adjust as needed for real org. + namespaceID := "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87" + nsUUID := uuid.MustParse(namespaceID) + token := "repoV2" + + var groupDescriptor string + var groupIdentity string + groupName := fmt.Sprintf("azdo-cli-test-group-%s", uuid.New().String()) + + inttest.Test(t, inttest.TestCase{ + Steps: []inttest.Step{ + { + PreRun: func(ctx inttest.TestContext) error { + grph, err := ctx.ClientFactory().Graph(ctx.Context(), ctx.Org()) + if err != nil { + return err + } + ext, err := ctx.ClientFactory().Extensions(ctx.Context(), ctx.Org()) + if err != nil { + return err + } + + group, err := grph.CreateGroupVsts(ctx.Context(), graph.CreateGroupVstsArgs{ + CreationContext: &graph.GraphGroupVstsCreationContext{ + DisplayName: &groupName, + }, + }) + if err != nil { + return fmt.Errorf("failed to create test group: %w", err) + } + groupDescriptor = *group.Descriptor + + identity, err := ext.ResolveIdentity(ctx.Context(), groupDescriptor) + if err != nil { + return err + } + groupIdentity = *identity.Descriptor + return nil + }, + Run: func(ctx inttest.TestContext) error { + o := &opts{ + rawTarget: fmt.Sprintf("%s/%s", ctx.Org(), groupDescriptor), + namespaceID: namespaceID, + token: token, + allowBits: []string{"0x2"}, + denyBits: []string{}, + merge: false, + yes: true, + } + return runCommand(ctx, o) + }, + Verify: func(ctx inttest.TestContext) error { + sec, err := ctx.ClientFactory().Security(ctx.Context(), ctx.Org()) + if err != nil { + return err + } + return inttest.Poll(func() error { + ace, err := shared.FindAccessControlEntry(ctx.Context(), sec, nsUUID, token, groupIdentity) + if err != nil { + return err + } + if ace == nil { + return fmt.Errorf("ace for descriptor %q (Identity: %q) not found", groupDescriptor, groupIdentity) + } + if ace.Allow == nil { + return fmt.Errorf("ace allow is nil; expected bit 0x2") + } + if *ace.Allow&0x2 == 0 { + return fmt.Errorf("allow mask %d does not contain expected bit 0x2", *ace.Allow) + } + return nil + }, inttest.PollOptions{ + Tries: 10, + Timeout: 30 * time.Second, + }) + }, + PostRun: func(ctx inttest.TestContext) error { + var errs []error + + sec, err := ctx.ClientFactory().Security(ctx.Context(), ctx.Org()) + if err != nil { + return err + } + + isRemoved, err := sec.RemoveAccessControlEntries(ctx.Context(), security.RemoveAccessControlEntriesArgs{ + SecurityNamespaceId: &nsUUID, + Token: &token, + Descriptors: &groupIdentity, + }) + if err != nil { + errs = append(errs, fmt.Errorf("failed to remove ACE: %w", err)) + } + if !*isRemoved { + errs = append(errs, fmt.Errorf("failed to remove ACE: status false")) + } + + grph, err := ctx.ClientFactory().Graph(ctx.Context(), ctx.Org()) + if err != nil { + return err + } + err = grph.DeleteGroup(ctx.Context(), graph.DeleteGroupArgs{ + GroupDescriptor: &groupDescriptor, + }) + if err != nil { + errs = append(errs, fmt.Errorf("failed to delete test group: %w", err)) + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil + }, + }, + }, + }) +} diff --git a/internal/cmd/security/permission/update/update_test.go b/internal/cmd/security/permission/update/update_test.go new file mode 100644 index 00000000..063c0ebb --- /dev/null +++ b/internal/cmd/security/permission/update/update_test.go @@ -0,0 +1,441 @@ +package update + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" + "github.com/stretchr/testify/require" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/mock/gomock" +) + +func TestUpdate_SetsACE_Success(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + + // Mocks + mCmdCtx := mocks.NewMockCmdContext(ctrl) + mClientFactory := mocks.NewMockClientFactory(ctrl) + mExtensionsClient := mocks.NewMockAzDOExtension(ctrl) + mIdentityClient := mocks.NewMockIdentityClient(ctrl) + mSecurityClient := mocks.NewMockSecurityClient(ctrl) + + // Baseline expectations (AnyTimes) + mCmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + mCmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + mCmdCtx.EXPECT().ClientFactory().Return(mClientFactory).AnyTimes() + + // Client factory returns clients for the requested organization + mClientFactory.EXPECT().Extensions(gomock.Any(), gomock.Any()).Return(mExtensionsClient, nil).AnyTimes() + mClientFactory.EXPECT().Identity(gomock.Any(), gomock.Any()).Return(mIdentityClient, nil).AnyTimes() + mClientFactory.EXPECT().Security(gomock.Any(), gomock.Any()).Return(mSecurityClient, nil).AnyTimes() + + // Simulate ResolveSubject -> returns a member with Descriptor set + ident := identity.Identity{ + Descriptor: types.ToPtr("storage-descriptor"), + } + mExtensionsClient.EXPECT().ResolveIdentity(gomock.Any(), gomock.Any()).Return(&ident, nil) + + // security.QuerySecurityNamespaces returns a non-nil slice (actions may be empty) + ns := security.SecurityNamespaceDescription{ + Name: types.ToPtr("testns"), + } + nsSlice := &[]security.SecurityNamespaceDescription{ns} + mSecurityClient.EXPECT().QuerySecurityNamespaces(gomock.Any(), gomock.Any()).Return(nsSlice, nil) + + // Build options matching command invocation + o := &opts{ + rawTarget: "org/user@example.com", + namespaceID: "00000000-0000-0000-0000-000000000000", + token: "token123", + allowBits: []string{"0x1"}, + denyBits: []string{}, + yes: true, + } + + // Expect SetAccessControlEntries to be called with a container containing token, merge flag and ACE with descriptor "acl-descriptor" + mSecurityClient.EXPECT().SetAccessControlEntries(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]interface{}, error) { + // Basic runtime checks to ensure arguments look correct + if args.SecurityNamespaceId == nil { + return nil, fmt.Errorf("SecurityNamespaceId is nil") + } + if !strings.EqualFold(args.SecurityNamespaceId.String(), o.namespaceID) { + return nil, fmt.Errorf("SecurityNamespaceId mismatch, expected %q got %q", args.SecurityNamespaceId.String(), o.namespaceID) + } + + if args.Container == nil { + return nil, fmt.Errorf("container is nil") + } + // Verify token present - Container is typed as interface{} in the SDK, so assert via type switch + switch c := args.Container.(type) { + case AccessControlEntryUpdate: + if c.Token != o.token { + return nil, fmt.Errorf("token does not equal %q", o.token) + } + + if len(c.AccessControlEntries) == 0 { + return nil, fmt.Errorf("accessControlEntries empty") + } + if c.AccessControlEntries[0].Descriptor == nil || types.GetValue(c.AccessControlEntries[0].Descriptor, "") != *ident.Descriptor { + return nil, fmt.Errorf("ace descriptor mismatch, expected %q got %q", *ident.Descriptor, types.GetValue(c.AccessControlEntries[0].Descriptor, "")) + } + default: + return nil, fmt.Errorf("container has unexpected type") + } + return &[]any{}, nil + }, + ) + + // Run command + err := runCommand(mCmdCtx, o) + if err != nil { + t.Fatalf("runCommand returned error: %v", err) + } + + // Assert output contains success message + require.NotEmpty(t, out.String(), "expected output to contain success message") +} + +func TestUpdate_ErrorWhenNoBits(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + mCmdCtx := mocks.NewMockCmdContext(ctrl) + mClientFactory := mocks.NewMockClientFactory(ctrl) + mExtensionsClient := mocks.NewMockAzDOExtension(ctrl) + mIdentityClient := mocks.NewMockIdentityClient(ctrl) + mSecurityClient := mocks.NewMockSecurityClient(ctrl) + + mCmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + mCmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + mCmdCtx.EXPECT().ClientFactory().Return(mClientFactory).AnyTimes() + + mClientFactory.EXPECT().Extensions(gomock.Any(), gomock.Any()).Return(mExtensionsClient, nil).AnyTimes() + mClientFactory.EXPECT().Identity(gomock.Any(), gomock.Any()).Return(mIdentityClient, nil).AnyTimes() + mClientFactory.EXPECT().Security(gomock.Any(), gomock.Any()).Return(mSecurityClient, nil).AnyTimes() + + ident := identity.Identity{ + Descriptor: types.ToPtr("storage-descriptor"), + } + mExtensionsClient.EXPECT().ResolveIdentity(gomock.Any(), gomock.Any()).Return(&ident, nil).AnyTimes() + + ns := security.SecurityNamespaceDescription{ + Name: types.ToPtr("testns"), + } + nsSlice := &[]security.SecurityNamespaceDescription{ns} + mSecurityClient.EXPECT().QuerySecurityNamespaces(gomock.Any(), gomock.Any()).Return(nsSlice, nil).AnyTimes() + + o := &opts{ + rawTarget: "org/user@example.com", + namespaceID: "00000000-0000-0000-0000-000000000000", + token: "token123", + allowBits: []string{}, + denyBits: []string{}, + yes: true, + } + + err := runCommand(mCmdCtx, o) + require.Error(t, err) +} + +func TestUpdate_SetsDenyBit(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + + // Mocks + mCmdCtx := mocks.NewMockCmdContext(ctrl) + mClientFactory := mocks.NewMockClientFactory(ctrl) + mExtensionsClient := mocks.NewMockAzDOExtension(ctrl) + mIdentityClient := mocks.NewMockIdentityClient(ctrl) + mSecurityClient := mocks.NewMockSecurityClient(ctrl) + + // Baseline expectations (AnyTimes) + mCmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + mCmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + mCmdCtx.EXPECT().ClientFactory().Return(mClientFactory).AnyTimes() + + mClientFactory.EXPECT().Extensions(gomock.Any(), gomock.Any()).Return(mExtensionsClient, nil).AnyTimes() + mClientFactory.EXPECT().Identity(gomock.Any(), gomock.Any()).Return(mIdentityClient, nil).AnyTimes() + mClientFactory.EXPECT().Security(gomock.Any(), gomock.Any()).Return(mSecurityClient, nil).AnyTimes() + + ident := identity.Identity{ + Descriptor: types.ToPtr("storage-descriptor"), + } + mExtensionsClient.EXPECT().ResolveIdentity(gomock.Any(), gomock.Any()).Return(&ident, nil) + + ns := security.SecurityNamespaceDescription{ + Name: types.ToPtr("testns"), + } + nsSlice := &[]security.SecurityNamespaceDescription{ns} + mSecurityClient.EXPECT().QuerySecurityNamespaces(gomock.Any(), gomock.Any()).Return(nsSlice, nil) + + // Expect SetAccessControlEntries and assert deny bit presence + mSecurityClient.EXPECT().SetAccessControlEntries(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]interface{}, error) { + if args.Container == nil { + return nil, fmt.Errorf("container is nil") + } + switch c := args.Container.(type) { + case map[string]interface{}: + // check deny exists on ACE + if acesI, ok := c["accessControlEntries"]; ok { + switch a := acesI.(type) { + case []security.AccessControlEntry: + if a[0].Deny == nil || types.GetValue(a[0].Deny, 0) == 0 { + return nil, fmt.Errorf("deny bit not set") + } + } + } + } + return &[]any{}, nil + }, + ) + + o := &opts{ + rawTarget: "org/user@example.com", + namespaceID: "00000000-0000-0000-0000-000000000000", + token: "token123", + allowBits: []string{}, + denyBits: []string{"0x2"}, + yes: true, + } + + err := runCommand(mCmdCtx, o) + require.NoError(t, err) + require.NotEmpty(t, out.String()) +} + +func TestUpdate_NoMergeFlag(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + + // Mocks + mCmdCtx := mocks.NewMockCmdContext(ctrl) + mClientFactory := mocks.NewMockClientFactory(ctrl) + mExtensionsClient := mocks.NewMockAzDOExtension(ctrl) + mIdentityClient := mocks.NewMockIdentityClient(ctrl) + mSecurityClient := mocks.NewMockSecurityClient(ctrl) + + // Baseline + mCmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + mCmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + mCmdCtx.EXPECT().ClientFactory().Return(mClientFactory).AnyTimes() + mClientFactory.EXPECT().Extensions(gomock.Any(), gomock.Any()).Return(mExtensionsClient, nil).AnyTimes() + mClientFactory.EXPECT().Identity(gomock.Any(), gomock.Any()).Return(mIdentityClient, nil).AnyTimes() + mClientFactory.EXPECT().Security(gomock.Any(), gomock.Any()).Return(mSecurityClient, nil).AnyTimes() + + ident := identity.Identity{ + Descriptor: types.ToPtr("storage-descriptor"), + } + mExtensionsClient.EXPECT().ResolveIdentity(gomock.Any(), gomock.Any()).Return(&ident, nil) + + ns := security.SecurityNamespaceDescription{ + Name: types.ToPtr("testns"), + } + nsSlice := &[]security.SecurityNamespaceDescription{ns} + mSecurityClient.EXPECT().QuerySecurityNamespaces(gomock.Any(), gomock.Any()).Return(nsSlice, nil) + + // Expect SetAccessControlEntries and assert merge flag false + mSecurityClient.EXPECT().SetAccessControlEntries(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]interface{}, error) { + if args.Container == nil { + return nil, fmt.Errorf("container is nil") + } + switch c := args.Container.(type) { + case map[string]interface{}: + if mv, ok := c["merge"].(bool); !ok { + return nil, fmt.Errorf("merge flag missing or not bool") + } else if mv { + return nil, fmt.Errorf("merge flag expected false") + } + } + return &[]any{}, nil + }, + ) + + o := &opts{ + rawTarget: "org/user@example.com", + namespaceID: "00000000-0000-0000-0000-000000000000", + token: "token123", + allowBits: []string{"0x1"}, + denyBits: []string{}, + yes: true, + merge: false, + } + + err := runCommand(mCmdCtx, o) + require.NoError(t, err) + require.NotEmpty(t, out.String()) +} + +func TestUpdate_MergeFlagTrue(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + + mCmdCtx := mocks.NewMockCmdContext(ctrl) + mClientFactory := mocks.NewMockClientFactory(ctrl) + mExtensionsClient := mocks.NewMockAzDOExtension(ctrl) + mIdentityClient := mocks.NewMockIdentityClient(ctrl) + mSecurityClient := mocks.NewMockSecurityClient(ctrl) + + mCmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + mCmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + mCmdCtx.EXPECT().ClientFactory().Return(mClientFactory).AnyTimes() + mClientFactory.EXPECT().Extensions(gomock.Any(), gomock.Any()).Return(mExtensionsClient, nil).AnyTimes() + mClientFactory.EXPECT().Identity(gomock.Any(), gomock.Any()).Return(mIdentityClient, nil).AnyTimes() + mClientFactory.EXPECT().Security(gomock.Any(), gomock.Any()).Return(mSecurityClient, nil).AnyTimes() + + ident := identity.Identity{ + Descriptor: types.ToPtr("storage-descriptor"), + } + mExtensionsClient.EXPECT().ResolveIdentity(gomock.Any(), gomock.Any()).Return(&ident, nil) + + ns := security.SecurityNamespaceDescription{ + Name: types.ToPtr("testns"), + } + nsSlice := &[]security.SecurityNamespaceDescription{ns} + mSecurityClient.EXPECT().QuerySecurityNamespaces(gomock.Any(), gomock.Any()).Return(nsSlice, nil) + + mSecurityClient.EXPECT().SetAccessControlEntries(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]any, error) { + switch c := args.Container.(type) { + case AccessControlEntryUpdate: + if !c.Merge { + return nil, fmt.Errorf("merge flag expected true: %v", c.Merge) + } + if len(c.AccessControlEntries) == 0 { + return nil, fmt.Errorf("aces missing or empty") + } + if allow := types.GetValue(c.AccessControlEntries[0].Allow, 0); allow != 1 { + return nil, fmt.Errorf("allow bit mismatch got %d", allow) + } + if deny := types.GetValue(c.AccessControlEntries[0].Deny, 0); deny != 2 { + return nil, fmt.Errorf("deny bit mismatch got %d", deny) + } + default: + return nil, fmt.Errorf("unexpected container type %T", args.Container) + } + return &[]any{}, nil + }, + ) + + o := &opts{ + rawTarget: "org/user@example.com", + namespaceID: "00000000-0000-0000-0000-000000000000", + token: "token123", + allowBits: []string{"0x1"}, + denyBits: []string{"0x2"}, + merge: true, + yes: true, + } + + err := runCommand(mCmdCtx, o) + require.NoError(t, err) + require.NotEmpty(t, out.String()) +} + +func TestParseBits(t *testing.T) { + actions := []security.ActionDefinition{ + { + Bit: types.ToPtr(1), + Name: types.ToPtr("Read"), + DisplayName: types.ToPtr("Read"), + }, + { + Bit: types.ToPtr(2), + Name: types.ToPtr("Edit"), + DisplayName: types.ToPtr("Modify"), + }, + { + Bit: types.ToPtr(4), + Name: types.ToPtr("Contribute"), + DisplayName: types.ToPtr("Contribute"), + }, + } + + tests := []struct { + name string + input []string + want int + wantErr bool + }{ + { + name: "hexadecimal value", + input: []string{"0x4"}, + want: 4, + }, + { + name: "decimal value", + input: []string{"2"}, + want: 2, + }, + { + name: "textual name", + input: []string{"Read"}, + want: 1, + }, + { + name: "display name", + input: []string{"Modify"}, + want: 2, + }, + { + name: "combined values", + input: []string{"Read", "0x2"}, + want: 3, + }, + { + name: "invalid token", + input: []string{"UnknownPermission"}, + wantErr: true, + }, + { + name: "unknown expression with hex", + input: []string{"Unknown (0x4)"}, + wantErr: true, + }, + { + name: "unknown bit", + input: []string{"0x8"}, + wantErr: true, + }, + { + name: "empty", + input: []string{}, + want: 0, + }, + { + name: "combined invalid values", + input: []string{"Read", "0x2", "0x8"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseBits(actions, tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 7c0673bf..d45a7545 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,9 @@ package config -import "go.uber.org/zap" +import ( + "github.com/tmeckel/azdo-cli/internal/yamlmap" + "go.uber.org/zap" +) const ( Aliases = "aliases" @@ -10,8 +13,6 @@ const ( ) // This interface describes interacting with some persistent configuration for azdo. -// -//go:generate moq -rm -out config_mock.go . Config type Config interface { Keys([]string) ([]string, error) Get([]string) (string, error) @@ -23,6 +24,18 @@ type Config interface { Aliases() AliasConfig } +type ConfigReader interface { + Read() (*yamlmap.Map, error) +} + +type defaultConfigReader struct{} + +func (cr *defaultConfigReader) Read() (*yamlmap.Map, error) { + return Read() +} + +var defCfgRdr = &defaultConfigReader{} + // Implements Config interface type cfg struct { cfg *configData @@ -31,12 +44,18 @@ type cfg struct { } func NewConfig() (Config, error) { - c, err := Read() + return NewConfigWithReader(defCfgRdr) +} + +func NewConfigWithReader(rd ConfigReader) (Config, error) { + c, err := rd.Read() if err != nil { return nil, err } cfg := &cfg{ - cfg: c, + cfg: &configData{ + entries: c, + }, } cfg.authCfg = &authConfig{ cfg: cfg, diff --git a/internal/config/config_data.go b/internal/config/config_data.go index 7227ae73..7aeb146f 100644 --- a/internal/config/config_data.go +++ b/internal/config/config_data.go @@ -23,12 +23,6 @@ const ( xdgStateHome = "XDG_STATE_HOME" ) -var ( - instance *configData - once sync.Once - errLoad error -) - // configData is a in memory representation of the azdo configuration files. // It can be thought of as map where entries consist of a key that // correspond to either a string value or a map value, allowing for @@ -133,9 +127,15 @@ func (c *configData) Set(keys []string, value string) { m.SetEntry(keys[len(keys)-1], yamlmap.StringValue(value)) } +var ( + instance *yamlmap.Map + once sync.Once + errLoad error +) + // Read azdo configuration files from the local file system and // return a configData. -var Read = func() (*configData, error) { +func Read() (*yamlmap.Map, error) { once.Do(func() { instance, errLoad = load(generalConfigFile(), organizationsConfigFile()) }) @@ -145,12 +145,33 @@ var Read = func() (*configData, error) { // ReadFromString takes a yaml string and returns a configData. // Note: This is only used for testing, and should not be // relied upon in production. -func ReadFromString(str string) *configData { //nolint:golint,revive - m, _ := mapFromString(str) +func ReadFromString(str string) (*yamlmap.Map, error) { //nolint:golint,revive + m, err := mapFromString(str) + if err != nil { + return nil, err + } if m == nil { m = yamlmap.MapValue() } - return &configData{entries: m} + return m, nil +} + +type stringConfigReader struct { + m *yamlmap.Map +} + +func NewStringConfigReader(str string) (ConfigReader, error) { + m, err := ReadFromString(str) + if err != nil { + return nil, err + } + return &stringConfigReader{ + m: m, + }, nil +} + +func (scr *stringConfigReader) Read() (*yamlmap.Map, error) { + return scr.m, nil } // Write azdo configuration files to the local file system. @@ -188,7 +209,7 @@ func Write(c *configData) error { return nil } -func load(generalFilePath, organizationsFilePath string) (*configData, error) { +func load(generalFilePath, organizationsFilePath string) (*yamlmap.Map, error) { generalMap, err := mapFromFile(generalFilePath) if err != nil && !os.IsNotExist(err) { if errors.Is(err, yamlmap.ErrInvalidYaml) || @@ -215,7 +236,7 @@ func load(generalFilePath, organizationsFilePath string) (*configData, error) { generalMap.AddEntry(Organizations, organizationsMap) } - return &configData{entries: generalMap}, nil + return generalMap, nil } func generalConfigFile() string { diff --git a/internal/mocks/config_mock.go b/internal/mocks/config_mock.go index 10f0e031..c1483849 100644 --- a/internal/mocks/config_mock.go +++ b/internal/mocks/config_mock.go @@ -13,6 +13,7 @@ import ( reflect "reflect" config "github.com/tmeckel/azdo-cli/internal/config" + yamlmap "github.com/tmeckel/azdo-cli/internal/yamlmap" gomock "go.uber.org/mock/gomock" ) @@ -152,3 +153,42 @@ func (mr *MockConfigMockRecorder) Write() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockConfig)(nil).Write)) } + +// MockConfigReader is a mock of ConfigReader interface. +type MockConfigReader struct { + ctrl *gomock.Controller + recorder *MockConfigReaderMockRecorder + isgomock struct{} +} + +// MockConfigReaderMockRecorder is the mock recorder for MockConfigReader. +type MockConfigReaderMockRecorder struct { + mock *MockConfigReader +} + +// NewMockConfigReader creates a new mock instance. +func NewMockConfigReader(ctrl *gomock.Controller) *MockConfigReader { + mock := &MockConfigReader{ctrl: ctrl} + mock.recorder = &MockConfigReaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConfigReader) EXPECT() *MockConfigReaderMockRecorder { + return m.recorder +} + +// Read mocks base method. +func (m *MockConfigReader) Read() (*yamlmap.Map, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read") + ret0, _ := ret[0].(*yamlmap.Map) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read. +func (mr *MockConfigReaderMockRecorder) Read() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockConfigReader)(nil).Read)) +} diff --git a/internal/mocks/extension_mock.go b/internal/mocks/extension_mock.go index d73e72cb..0f0a27aa 100644 --- a/internal/mocks/extension_mock.go +++ b/internal/mocks/extension_mock.go @@ -15,6 +15,7 @@ import ( uuid "github.com/google/uuid" graph "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" + identity "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" gomock "go.uber.org/mock/gomock" ) @@ -86,3 +87,33 @@ func (mr *MockAzDOExtensionMockRecorder) GetSubjectID(ctx, subject any) *gomock. mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubjectID", reflect.TypeOf((*MockAzDOExtension)(nil).GetSubjectID), ctx, subject) } + +// ResolveIdentity mocks base method. +func (m *MockAzDOExtension) ResolveIdentity(ctx context.Context, member string) (*identity.Identity, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveIdentity", ctx, member) + ret0, _ := ret[0].(*identity.Identity) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveIdentity indicates an expected call of ResolveIdentity. +func (mr *MockAzDOExtensionMockRecorder) ResolveIdentity(ctx, member any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveIdentity", reflect.TypeOf((*MockAzDOExtension)(nil).ResolveIdentity), ctx, member) +} + +// ResolveSubject mocks base method. +func (m *MockAzDOExtension) ResolveSubject(ctx context.Context, member string) (*graph.GraphSubject, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveSubject", ctx, member) + ret0, _ := ret[0].(*graph.GraphSubject) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveSubject indicates an expected call of ResolveSubject. +func (mr *MockAzDOExtensionMockRecorder) ResolveSubject(ctx, member any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveSubject", reflect.TypeOf((*MockAzDOExtension)(nil).ResolveSubject), ctx, member) +} diff --git a/internal/mocks/security_client_mock.go b/internal/mocks/security_client_mock.go new file mode 100644 index 00000000..6dee9c2f --- /dev/null +++ b/internal/mocks/security_client_mock.go @@ -0,0 +1,176 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/microsoft/azure-devops-go-api/azuredevops/v7/security (interfaces: Client) +// +// Generated by this command: +// +// mockgen -package=mocks -destination internal/mocks/security_client_mock.go -mock_names Client=MockSecurityClient github.com/microsoft/azure-devops-go-api/azuredevops/v7/security Client +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + security "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" + gomock "go.uber.org/mock/gomock" +) + +// MockSecurityClient is a mock of Client interface. +type MockSecurityClient struct { + ctrl *gomock.Controller + recorder *MockSecurityClientMockRecorder + isgomock struct{} +} + +// MockSecurityClientMockRecorder is the mock recorder for MockSecurityClient. +type MockSecurityClientMockRecorder struct { + mock *MockSecurityClient +} + +// NewMockSecurityClient creates a new mock instance. +func NewMockSecurityClient(ctrl *gomock.Controller) *MockSecurityClient { + mock := &MockSecurityClient{ctrl: ctrl} + mock.recorder = &MockSecurityClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSecurityClient) EXPECT() *MockSecurityClientMockRecorder { + return m.recorder +} + +// HasPermissions mocks base method. +func (m *MockSecurityClient) HasPermissions(arg0 context.Context, arg1 security.HasPermissionsArgs) (*[]bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasPermissions", arg0, arg1) + ret0, _ := ret[0].(*[]bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasPermissions indicates an expected call of HasPermissions. +func (mr *MockSecurityClientMockRecorder) HasPermissions(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissions", reflect.TypeOf((*MockSecurityClient)(nil).HasPermissions), arg0, arg1) +} + +// HasPermissionsBatch mocks base method. +func (m *MockSecurityClient) HasPermissionsBatch(arg0 context.Context, arg1 security.HasPermissionsBatchArgs) (*security.PermissionEvaluationBatch, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasPermissionsBatch", arg0, arg1) + ret0, _ := ret[0].(*security.PermissionEvaluationBatch) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasPermissionsBatch indicates an expected call of HasPermissionsBatch. +func (mr *MockSecurityClientMockRecorder) HasPermissionsBatch(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionsBatch", reflect.TypeOf((*MockSecurityClient)(nil).HasPermissionsBatch), arg0, arg1) +} + +// QueryAccessControlLists mocks base method. +func (m *MockSecurityClient) QueryAccessControlLists(arg0 context.Context, arg1 security.QueryAccessControlListsArgs) (*[]security.AccessControlList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryAccessControlLists", arg0, arg1) + ret0, _ := ret[0].(*[]security.AccessControlList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryAccessControlLists indicates an expected call of QueryAccessControlLists. +func (mr *MockSecurityClientMockRecorder) QueryAccessControlLists(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryAccessControlLists", reflect.TypeOf((*MockSecurityClient)(nil).QueryAccessControlLists), arg0, arg1) +} + +// QuerySecurityNamespaces mocks base method. +func (m *MockSecurityClient) QuerySecurityNamespaces(arg0 context.Context, arg1 security.QuerySecurityNamespacesArgs) (*[]security.SecurityNamespaceDescription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QuerySecurityNamespaces", arg0, arg1) + ret0, _ := ret[0].(*[]security.SecurityNamespaceDescription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QuerySecurityNamespaces indicates an expected call of QuerySecurityNamespaces. +func (mr *MockSecurityClientMockRecorder) QuerySecurityNamespaces(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySecurityNamespaces", reflect.TypeOf((*MockSecurityClient)(nil).QuerySecurityNamespaces), arg0, arg1) +} + +// RemoveAccessControlEntries mocks base method. +func (m *MockSecurityClient) RemoveAccessControlEntries(arg0 context.Context, arg1 security.RemoveAccessControlEntriesArgs) (*bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveAccessControlEntries", arg0, arg1) + ret0, _ := ret[0].(*bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveAccessControlEntries indicates an expected call of RemoveAccessControlEntries. +func (mr *MockSecurityClientMockRecorder) RemoveAccessControlEntries(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAccessControlEntries", reflect.TypeOf((*MockSecurityClient)(nil).RemoveAccessControlEntries), arg0, arg1) +} + +// RemoveAccessControlLists mocks base method. +func (m *MockSecurityClient) RemoveAccessControlLists(arg0 context.Context, arg1 security.RemoveAccessControlListsArgs) (*bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveAccessControlLists", arg0, arg1) + ret0, _ := ret[0].(*bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveAccessControlLists indicates an expected call of RemoveAccessControlLists. +func (mr *MockSecurityClientMockRecorder) RemoveAccessControlLists(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAccessControlLists", reflect.TypeOf((*MockSecurityClient)(nil).RemoveAccessControlLists), arg0, arg1) +} + +// RemovePermission mocks base method. +func (m *MockSecurityClient) RemovePermission(arg0 context.Context, arg1 security.RemovePermissionArgs) (*security.AccessControlEntry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePermission", arg0, arg1) + ret0, _ := ret[0].(*security.AccessControlEntry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemovePermission indicates an expected call of RemovePermission. +func (mr *MockSecurityClientMockRecorder) RemovePermission(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePermission", reflect.TypeOf((*MockSecurityClient)(nil).RemovePermission), arg0, arg1) +} + +// SetAccessControlEntries mocks base method. +func (m *MockSecurityClient) SetAccessControlEntries(arg0 context.Context, arg1 security.SetAccessControlEntriesArgs) (*[]security.AccessControlEntry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetAccessControlEntries", arg0, arg1) + ret0, _ := ret[0].(*[]security.AccessControlEntry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetAccessControlEntries indicates an expected call of SetAccessControlEntries. +func (mr *MockSecurityClientMockRecorder) SetAccessControlEntries(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccessControlEntries", reflect.TypeOf((*MockSecurityClient)(nil).SetAccessControlEntries), arg0, arg1) +} + +// SetAccessControlLists mocks base method. +func (m *MockSecurityClient) SetAccessControlLists(arg0 context.Context, arg1 security.SetAccessControlListsArgs) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetAccessControlLists", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetAccessControlLists indicates an expected call of SetAccessControlLists. +func (mr *MockSecurityClientMockRecorder) SetAccessControlLists(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccessControlLists", reflect.TypeOf((*MockSecurityClient)(nil).SetAccessControlLists), arg0, arg1) +} diff --git a/internal/test/acc_helpers.go b/internal/test/acc_helpers.go new file mode 100644 index 00000000..ecffc5c6 --- /dev/null +++ b/internal/test/acc_helpers.go @@ -0,0 +1,267 @@ +package test + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "gopkg.in/yaml.v3" + + "github.com/tmeckel/azdo-cli/internal/azdo" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/config" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/printer" + "github.com/tmeckel/azdo-cli/internal/prompter" +) + +const ( + accToggleEnv = "AZDO_ACC_TEST" + accOrgEnv = "AZDO_ACC_ORG" + accOrgURLEnv = "AZDO_ACC_ORG_URL" + accPATEnv = "AZDO_ACC_PAT" + accTimeoutSeconds = 60 + accTimeoutEnv = "AZDO_ACC_TIMEOUT" +) + +// nullPrinter is a no-op implementation of printer.Printer used for acceptance +// tests where formatted output is not needed. Methods are no-ops and Render() returns nil. +type nullPrinter struct{} + +func (n *nullPrinter) AddColumns(columns ...string) { +} + +func (n *nullPrinter) AddField(s string, _ ...printer.FieldOption) { +} + +func (n *nullPrinter) AddTimeField(now, t time.Time, _ func(string) string) { +} + +func (n *nullPrinter) EndRow() { +} + +func (n *nullPrinter) Render() error { + return nil +} + +type TestCase struct { + PreCheck func() error + Steps []Step +} + +type TestContext interface { + util.CmdContext + Org() string + OrgUrl() string + PAT() string +} + +type acceptanceCmdContext struct { + baseCtx context.Context + ios *iostreams.IOStreams + cfg config.Config + connFactory azdo.ConnectionFactory + clientFactory azdo.ClientFactory +} + +var _ util.CmdContext = (*acceptanceCmdContext)(nil) + +func (a *acceptanceCmdContext) Context() context.Context { return a.baseCtx } +func (a *acceptanceCmdContext) RepoContext() util.RepoContext { return nil } +func (a *acceptanceCmdContext) ConnectionFactory() azdo.ConnectionFactory { return a.connFactory } +func (a *acceptanceCmdContext) ClientFactory() azdo.ClientFactory { return a.clientFactory } +func (a *acceptanceCmdContext) Prompter() (prompter.Prompter, error) { + return nil, fmt.Errorf("not implemented") +} +func (a *acceptanceCmdContext) Config() (config.Config, error) { return a.cfg, nil } +func (a *acceptanceCmdContext) IOStreams() (*iostreams.IOStreams, error) { return a.ios, nil } +func (a *acceptanceCmdContext) Printer(string) (printer.Printer, error) { + return &nullPrinter{}, nil +} + +type testContext struct { + org string + orgURL string + pat string + util.CmdContext +} + +// Ensure testContext implements util.CmdContext by delegation. +var _ util.CmdContext = (*testContext)(nil) + +func (tc *testContext) Org() string { + return tc.org +} + +func (tc *testContext) OrgUrl() string { + return tc.orgURL +} + +func (tc *testContext) PAT() string { + return tc.pat +} + +// Precheck and context builder +func newTestContext(t *testing.T) TestContext { + org := os.Getenv(accOrgEnv) + orgurl := os.Getenv(accOrgURLEnv) + pat := os.Getenv(accPATEnv) + + if org == "" || pat == "" { + t.Fatalf("missing acceptance env variables: %q, %q", accOrgEnv, accPATEnv) + } + + if orgurl == "" { + orgurl = fmt.Sprintf("https://dev.azure.com/%s", org) + } + // Build a safe YAML configuration using marshaling instead of fmt.Sprintf interpolation. + // This avoids accidental YAML-breaking characters in env values. + cfgData := map[string]any{ + "organizations": map[string]any{ + org: map[string]any{ + "url": orgurl, + "pat": pat, + "git_protocol": "https", + }, + }, + } + cfgBytes, err := yaml.Marshal(cfgData) + if err != nil { + t.Fatalf("failed to marshal config YAML: %v", err) + } + cfgRdr, err := config.NewStringConfigReader(string(cfgBytes)) + if err != nil { + t.Fatalf("failed to create ConfigReader %v", err) + } + + cfg, err := config.NewConfigWithReader(cfgRdr) + if err != nil { + t.Fatalf("failed to create config %v", err) + } + auth, err := util.NewPatAuthenticator(cfg) + if err != nil { + t.Fatalf("failed to create PAT authenticator %v", err) + } + + connFactory, err := azdo.NewConnectionFactory(cfg, auth) + if err != nil { + t.Fatalf("failed to create azdo connection factory: %v", err) + } + clientFactory, err := azdo.NewClientFactory(connFactory) + if err != nil { + t.Fatalf("failed to create azdo client factory: %v", err) + } + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + // Determine timeout: default accTimeoutSeconds, but allow override via AZDO_ACC_TIMEOUT. + // Accept full duration strings (e.g., "30s", "1m") or a plain integer interpreted as seconds for backwards compatibility. + var baseCtx context.Context + var cancel context.CancelFunc + + timeoutVal := os.Getenv(accTimeoutEnv) + debugVal := os.Getenv("AZDO_DEBUG") + + if timeoutVal == "-1" || debugVal == "1" { + baseCtx, cancel = context.WithCancel(context.Background()) + } else { + timeoutSec := accTimeoutSeconds + if timeoutVal != "" { + // Try parsing as a full duration first. + if parsedDur, err := time.ParseDuration(timeoutVal); err == nil { + if parsedDur <= 0 { + t.Fatalf("invalid %s value '%s' — duration must be > 0", accTimeoutEnv, timeoutVal) + } + timeoutSec = int(parsedDur / time.Second) + } else { + // Backwards-compatible integer-only parse (seconds). + var pi int + if _, err2 := fmt.Sscanf(timeoutVal, "%d", &pi); err2 == nil && pi > 0 { + timeoutSec = pi + } else { + t.Fatalf("invalid %s value '%s' — provide a duration (e.g. \"30s\") or a positive integer (seconds)", accTimeoutEnv, timeoutVal) + } + } + } + baseCtx, cancel = context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + } + t.Cleanup(cancel) + + return &testContext{ + org: org, + orgURL: orgurl, + pat: pat, + CmdContext: &acceptanceCmdContext{ + baseCtx: baseCtx, + ios: ios, + cfg: cfg, + connFactory: connFactory, + clientFactory: clientFactory, + }, + } +} + +// Compact acc runner +type Step struct { + PreRun func(TestContext) error + Run func(TestContext) error + PostRun func(TestContext) error + Verify func(TestContext) error +} + +// RunStep executes a single Step and returns an aggregated error. +// It guarantees PostRun is executed regardless of Run or Verify outcome. +func runStep(ctx TestContext, s Step) error { + var errs []error + + if s.PreRun != nil { + if err := s.PreRun(ctx); err != nil { + return fmt.Errorf("pre: %w", err) + } + } + + if s.Run != nil { + if err := s.Run(ctx); err != nil { + errs = append(errs, fmt.Errorf("run: %w", err)) + } + } + + if s.Verify != nil && len(errs) == 0 { + if err := s.Verify(ctx); err != nil { + errs = append(errs, fmt.Errorf("verify: %w", err)) + } + } + + // Always attempt PostRun cleanup regardless of Run/Verify outcome. + if s.PostRun != nil { + if err := s.PostRun(ctx); err != nil { + errs = append(errs, fmt.Errorf("post: %w", err)) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func Test(t *testing.T, tc TestCase) { + if os.Getenv(accToggleEnv) == "" { + t.Skipf("Acceptance tests skipped unless env '%s' set", accToggleEnv) + return + } + + if tc.PreCheck != nil { + if err := tc.PreCheck(); err != nil { + t.Fatalf("test PreCheck failed: %v", err) + } + } + ctx := newTestContext(t) + for _, s := range tc.Steps { + if err := runStep(ctx, s); err != nil { + t.Fatalf("%v", err) + } + } +} diff --git a/internal/test/acc_helpers_test.go b/internal/test/acc_helpers_test.go new file mode 100644 index 00000000..7590cd17 --- /dev/null +++ b/internal/test/acc_helpers_test.go @@ -0,0 +1,238 @@ +package test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/tmeckel/azdo-cli/internal/config" + "github.com/tmeckel/azdo-cli/internal/iostreams" +) + +// TestNullPrinter_NoOps verifies that calling nullPrinter methods doesn't panic +// and Render returns nil. +func TestNullPrinter_NoOps(t *testing.T) { + p := &nullPrinter{} + + // Call each method with plausible inputs to ensure no panics and no errors. + p.AddColumns("col1", "col2") + p.AddField("value1") + p.AddTimeField(time.Now(), time.Now(), func(s string) string { return s }) + p.EndRow() + + if err := p.Render(); err != nil { + t.Fatalf("nullPrinter.Render() returned unexpected error: %v", err) + } +} + +// TestAcceptanceCmdContext_Printer ensures acceptanceCmdContext.Printer returns +// a non-nil nullPrinter without relying on external resources. +func TestAcceptanceCmdContext_Printer(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + acc := &acceptanceCmdContext{ + baseCtx: context.Background(), + ios: ios, + // other fields intentionally zero-valued because Printer does not use them + } + + p, err := acc.Printer("anything") + if err != nil { + t.Fatalf("Printer returned unexpected error: %v", err) + } + if p == nil { + t.Fatalf("Printer returned nil, expected non-nil printer") + } + + // Render should succeed (nullPrinter Render returns nil). + if err := p.Render(); err != nil { + t.Fatalf("printer.Render returned unexpected error: %v", err) + } +} + +// TestTestContext_OrgFields verifies that testContext exposes Org, OrgUrl and PAT +// values set at construction time and that embedding a CmdContext does not +// prevent those accessors from working. +func TestTestContext_OrgFields(t *testing.T) { + // Prepare unique values and set them with t.Setenv so they're restored automatically. + org := "example-org-env" + orgURL := "https://example.org/env" + pat := "env-secret-pat" + + t.Setenv(accOrgEnv, org) + t.Setenv(accOrgURLEnv, orgURL) + t.Setenv(accPATEnv, pat) + + // newTestContext validates env vars and returns a TestContext built from them. + tc := newTestContext(t) + + // Verify that the TestContext accessors return the expected values from env. + if got := tc.Org(); got != org { + t.Fatalf("Org() = %q, want %q", got, org) + } + if got := tc.OrgUrl(); got != orgURL { + t.Fatalf("OrgUrl() = %q, want %q", got, orgURL) + } + if got := tc.PAT(); got != pat { + t.Fatalf("PAT() = %q, want %q", got, pat) + } +} + +// TestNewTestContext_Config ensures that newTestContext builds a config from +// environment variables. It uses t.Setenv so env is restored automatically. +func TestNewTestContext_Config(t *testing.T) { + // Prepare unique values + org := "test-org-for-unit" + orgURL := "https://dev.azure.test/test-org-for-unit" + pat := "TEST_PAT_VALUE" + + // Use t.Setenv so the testing framework will automatically restore values. + t.Setenv(accOrgEnv, org) + t.Setenv(accOrgURLEnv, orgURL) + t.Setenv(accPATEnv, pat) + + // Call newTestContext which will build a config from the env vars. + tc := newTestContext(t) + + // Retrieve the underlying config via TestContext.Config() + cfg, err := tc.Config() + if err != nil { + t.Fatalf("failed to get config from TestContext: %v", err) + } + + // Verify values in config match the env we set. + gotURL, _ := cfg.Get([]string{config.Organizations, org, "url"}) + if gotURL != orgURL { + t.Fatalf("config organizations.%s.url = %q, want %q", org, gotURL, orgURL) + } + gotPAT, _ := cfg.Get([]string{config.Organizations, org, "pat"}) + if gotPAT != pat { + t.Fatalf("config organizations.%s.pat = %q, want %q", org, gotPAT, pat) + } +} + +// TestRunStep_PostRunAlwaysRuns verifies that PostRun runs even when Run returns an error. +func TestRunStep_PostRunAlwaysRuns_RunFails(t *testing.T) { + called := struct { + run bool + verify bool + postrun bool + }{} + + step := Step{ + Run: func(ctx TestContext) error { + called.run = true + return errors.New("run failure") + }, + Verify: func(ctx TestContext) error { + called.verify = true + return nil + }, + PostRun: func(ctx TestContext) error { + called.postrun = true + return nil + }, + } + + // Use a minimal testContext: newTestContext requires env vars, so craft a small stub. + tc := &testContext{ + org: "o", + orgURL: "u", + pat: "p", + CmdContext: &acceptanceCmdContext{ + baseCtx: nil, + ios: nil, + }, + } + + err := runStep(tc, step) + if err == nil { + t.Fatalf("expected error from RunStep when Run fails") + } + // Ensure PostRun executed despite Run failing. + if !called.postrun { + t.Fatalf("PostRun was not executed when Run failed") + } +} + +// TestRunStep_PostRunAlwaysRuns verifies that PostRun runs even when Verify returns an error. +func TestRunStep_PostRunAlwaysRuns_VerifyFails(t *testing.T) { + called := struct { + run bool + verify bool + postrun bool + }{} + + step := Step{ + Run: func(ctx TestContext) error { + called.run = true + return nil + }, + Verify: func(ctx TestContext) error { + called.verify = true + return errors.New("verify failure") + }, + PostRun: func(ctx TestContext) error { + called.postrun = true + return nil + }, + } + + tc := &testContext{ + org: "o", + orgURL: "u", + pat: "p", + CmdContext: &acceptanceCmdContext{ + baseCtx: nil, + ios: nil, + }, + } + + err := runStep(tc, step) + if err == nil { + t.Fatalf("expected error from RunStep when Verify fails") + } + // Ensure PostRun executed despite Verify failing. + if !called.postrun { + t.Fatalf("PostRun was not executed when Verify failed") + } +} + +// TestRunStep_AggregatesErrors ensures that multiple errors are preserved/aggregated. +func TestRunStep_AggregatesErrors(t *testing.T) { + step := Step{ + Run: func(ctx TestContext) error { + return errors.New("run-err") + }, + Verify: func(ctx TestContext) error { + return errors.New("verify-err") + }, + PostRun: func(ctx TestContext) error { + return errors.New("post-err") + }, + } + + tc := &testContext{ + org: "o", + orgURL: "u", + pat: "p", + CmdContext: &acceptanceCmdContext{ + baseCtx: nil, + ios: nil, + }, + } + + err := runStep(tc, step) + if err == nil { + t.Fatalf("expected aggregated error from RunStep") + } + + // The aggregated error string should contain each individual message. + msg := err.Error() + if !strings.Contains(msg, "run-err") || !strings.Contains(msg, "post-err") { + t.Fatalf("aggregated error missing components: %v", msg) + } +} diff --git a/internal/test/poll.go b/internal/test/poll.go new file mode 100644 index 00000000..57c63856 --- /dev/null +++ b/internal/test/poll.go @@ -0,0 +1,73 @@ +package test + +import ( + "fmt" + "math" + "time" +) + +// PollFunc is the function to be executed by the Poll function. +// It should return an error to indicate a failure and that it should be retried. +// A nil error indicates success. +type PollFunc func() error + +// PollOptions configures the behavior of the Poll function. +type PollOptions struct { + // Tries is the maximum number of times to try the function. + // If Tries is 0, it will retry until Timeout is reached. + Tries int + // Delay is the time to wait between retries. + // If Delay is 0, binary exponential backoff is used, starting at 2 seconds. + Delay time.Duration + // Timeout is the maximum total time to spend retrying. + // If Timeout is 0, there is no time limit. + Timeout time.Duration +} + +// Poll executes the given function `fn` until it returns no error, or until the +// configured number of tries or timeout is reached. +func Poll(fn PollFunc, opts PollOptions) error { + var lastErr error + + if opts.Tries == 0 && opts.Timeout == 0 { + opts.Tries = 1 + } + + startTime := time.Now() + for i := 0; opts.Tries == 0 || i < opts.Tries; i++ { + if opts.Timeout > 0 && time.Since(startTime) > opts.Timeout { + if lastErr != nil { + return fmt.Errorf("timed out after %v, last error: %w", opts.Timeout, lastErr) + } + return fmt.Errorf("timed out after %v", opts.Timeout) + } + + err := fn() + if err == nil { + return nil + } + lastErr = err + + if opts.Tries > 0 && i == opts.Tries-1 { + break + } + + var wait time.Duration + if opts.Delay > 0 { + wait = opts.Delay + } else { + // Binary exponential backoff with initial 2 seconds + wait = time.Duration(math.Pow(2, float64(i))) * 2 * time.Second + } + time.Sleep(wait) + } + + if lastErr != nil { + if opts.Tries > 0 { + return fmt.Errorf("after %d attempts, last error: %w", opts.Tries, lastErr) + } + return fmt.Errorf("last error: %w", lastErr) + } + + return fmt.Errorf("polling failed without returning an error") +} diff --git a/internal/test/poll_test.go b/internal/test/poll_test.go new file mode 100644 index 00000000..16db92de --- /dev/null +++ b/internal/test/poll_test.go @@ -0,0 +1,109 @@ +package test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPollDefaultsToSingleTry(t *testing.T) { + var calls int + + err := Poll(func() error { + calls++ + return nil + }, PollOptions{}) + + require.NoError(t, err) + require.Equal(t, 1, calls) +} + +func TestPollRetriesUntilSuccess(t *testing.T) { + var calls int + errBoom := errors.New("temporary failure") + + err := Poll(func() error { + calls++ + if calls < 3 { + return errBoom + } + + return nil + }, PollOptions{ + Tries: 5, + Delay: time.Millisecond, + }) + + require.NoError(t, err) + require.Equal(t, 3, calls) +} + +func TestPollReturnsAfterMaxAttempts(t *testing.T) { + var calls int + errBoom := errors.New("permanent failure") + + err := Poll(func() error { + calls++ + return errBoom + }, PollOptions{ + Tries: 3, + Delay: time.Millisecond, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "after 3 attempts") + require.ErrorIs(t, err, errBoom) + require.Equal(t, 3, calls) +} + +func TestPollTimeout(t *testing.T) { + var calls int + errBoom := errors.New("timeout failure") + + err := Poll(func() error { + calls++ + return errBoom + }, PollOptions{ + Delay: 20 * time.Millisecond, + Timeout: 30 * time.Millisecond, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "timed out after") + require.ErrorContains(t, err, errBoom.Error()) + require.GreaterOrEqual(t, calls, 2) +} + +func TestPollBinaryExponentialBackoff(t *testing.T) { + // Expected waits: 2s, 4s, 8s (formula: 2^i * 2s) + expectedDurations := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second} + var lastCall time.Time + var callIndex int + + err := Poll(func() error { + now := time.Now() + if callIndex > 0 { + elapsed := now.Sub(lastCall) + expected := expectedDurations[callIndex-1] + + // Allow ±10% margin for scheduler jitter + min := expected - expected/10 + max := expected + expected/10 + if elapsed < min || elapsed > max { + t.Fatalf("Call %d: expected ~%v wait, got %v", callIndex, expected, elapsed) + } + } + if callIndex >= len(expectedDurations) { + return nil // succeed after verifying waits + } + lastCall = now + callIndex++ + return errors.New("simulated failure") + }, PollOptions{ + Tries: len(expectedDurations) + 1, // enough tries to verify all intervals + }) // Delay left at zero + + require.NoError(t, err) +} diff --git a/internal/text/text.go b/internal/text/text.go index ed2f6686..5148fa93 100644 --- a/internal/text/text.go +++ b/internal/text/text.go @@ -225,8 +225,8 @@ func (b *FormatSliceBuilder) String() string { currentLineLength += len(sep) } } else { - if !isLast && currentLineLength+len(ws)+len(v)+len(sep) > int(b.lineLen) || //nolint:gosec - isLast && currentLineLength+len(ws)+len(v) > int(b.lineLen) { //nolint:gosec + if !isLast && currentLineLength+len(ws)+len(v)+len(sep) > b.lineLen || //nolint:gosec + isLast && currentLineLength+len(ws)+len(v) > b.lineLen { //nolint:gosec currentLineLength = 0 builder.WriteString("\n") i-- diff --git a/scripts/generate_mocks.sh b/scripts/generate_mocks.sh index fc734f72..0e2207c2 100644 --- a/scripts/generate_mocks.sh +++ b/scripts/generate_mocks.sh @@ -47,6 +47,12 @@ mockgen \ -mock_names Client=MockIdentityClient \ github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity Client +echo "Generating Azure DevOps Security client mock..." +mockgen \ + -package=mocks -destination internal/mocks/security_client_mock.go \ + -mock_names Client=MockSecurityClient \ + github.com/microsoft/azure-devops-go-api/azuredevops/v7/security Client + echo "Generating Repository mock..." mockgen -source internal/azdo/repo.go \ -package=mocks -destination internal/mocks/repository_mock.go