authorization: Update AI development principles from SSOT (20261103)

Hand-fixes

This distilled output was hand-fixed on the sync branch after review, as doc/development/ai_instruction_files_review.md requires calling out:

  • f8768894 removes a duplicate ## Authoritative sources footer from permissions-fundamentals (#637982) and adds the new delegate_to guidance the run missed; see #604659.
  • 3486dd06 restores the condition that conventions.md puts on the owner grant and UI notice for directing non-human accounts (applies only when a lower-privileged user deliberately directs a higher-privileged account), as suggested in review.

Check these lines against the SSOT along with the generated changes.

Summary

This MR updates AI development principles based on recent changes to the development documentation (SSOT). It is one of several team-scoped MRs from this run; the global routing tables (AGENTS.md, SKILL.md) are updated in a separate tooling MR.

Draft until GitLab Duo's review is addressed. GitLab Duo reviews this MR first, and its findings are worked through with the ai-principles-review-feedback skill. Human reviewers: please start once this MR is marked ready.

Could the authors listed below review the principles distilled from their SSOT changes?

Author SSOT commit(s)
@imand3r 78e4b7d1, de63edf4, 5b107dd9
@pmartinsgl efe075dc
@jmrp 2c34a02f
Why you were pinged, and why this needs merging

You were pinged because you changed the SSOT documentation these principles are distilled from.

These MRs need to be reviewed and merged, not just reviewed: the distilled principles only reach the gitlab-coding-principles that AI agents load once this is merged. An unmerged MR is re-distilled and reopened on the next weekly run, so the improvement stays unavailable to agents until someone merges it.

Updated principles and their source-doc changes

permissions-fundamentals

Source files (8)
SSOT diff since previous distillation (8f7fe15d → 2019a7ae)
diff --git a/doc/development/permissions/conventions.md b/doc/development/permissions/conventions.md
index a27222497..4b4b25f0f 100644
--- a/doc/development/permissions/conventions.md
+++ b/doc/development/permissions/conventions.md
@@ -24,6 +24,28 @@ Permissions are referenced by [role definition YAML files](role_definitions.md)
 [assignable permission groups](granular_access/rest_api_implementation_guide.md#step-4-create-or-update-assignable-permissions)
 (for granular PAT scoping).
 
+## Directing non-human accounts
+
+When a feature lets a user configure or direct a non-human account (service account, bot, or agent)
+that already holds permissions, that user must not, by doing so, gain effective access beyond
+their own role and permissions on the affected resources.
+
+If we intentionally want a lower-privileged user to direct a higher-privileged account, a
+higher-privileged owner must explicitly grant both the account's role and the right for that user
+or role to direct what the account does. The UI must make that delegation and its consequences clear.
+
+Starting predefined work isn't the same as directing an account. For example, a feature can let a
+lower-privileged user trigger a security policy pipeline on specific resources. Without explicit
+delegation, that user must not be able to change the privileged commands or point them at other
+resources, including through pipeline inputs.
+
+Composite identity paths and autonomous paths are different:
+
+- Composite identity: the account acts on behalf of a user, so it can only do what both the account
+  and the user are allowed to do.
+- Autonomous: the account acts with its own permissions, so the feature must check that every user
+  who can configure or direct the account has the access they need, or an explicit delegation.
+
 ## Naming Permissions
 
 Our goal is for all permissions to follow a consistent pattern: `action_resource(_subresource)`. These guidelines apply to both Assignable Permissions and Raw Permissions, but must be most strictly followed with Assignable Permissions as they are public facing.
diff --git a/doc/development/permissions/review_guidelines.md b/doc/development/permissions/review_guidelines.md
index 4496c7a7c..f23a27f09 100644
--- a/doc/development/permissions/review_guidelines.md
+++ b/doc/development/permissions/review_guidelines.md
@@ -21,7 +21,7 @@ Each item links to the section with details.
 - [Policy rules](#anti-patterns) only restrict with `prevent` (except the [private-permission pattern](#exception-private-permissions)), do not look up access levels, do not derive a permission from [other permissions](#do-not-derive-a-permission-from-other-permissions), and do not grant access from another resource.
 - [Condition names](#name-conditions-for-what-they-check) describe what the condition checks, and `can?(:read_project)` is [not used as a membership check](#do-not-use-canread_project-as-a-membership-check).
 - [Custom roles](#custom-roles) are checked when a cascade (one permission enabling another), name, or permission is removed or changed.
-- [Enforcement](#where-to-enforce) uses the policy in every entry point, including services and finders, unless an [exception](authorizations.md#exceptions) applies.
+- [Enforcement](#where-to-enforce) uses the policy in every entry point, including services and finders, unless an [exception](authorizations.md#exceptions) applies. Listing and fetching a resource check the same permission.
 - [Granular tokens](#granular-token-authorization) are declared or explicitly opted out for every new endpoint.
 - [GraphQL authorization](#graphql-authorization) is on the type where possible, rather than on individual fields.
 - [Specs](#testing) cover every policy change, with real memberships and settings instead of stubs.
@@ -479,6 +479,8 @@ See [Avoid cascading permissions through intermediate abilities](#avoid-cascadin
   Do not re-implement it in controllers, helpers, or views, because the copy can drift and bypass the policy.
 - Enforce in services and finders too, and use the same permission across REST, GraphQL, and controllers.
   For guidance on where to check, see [Where should permissions be checked?](authorizations.md).
+- Use the same permission to list a resource and to fetch one by ID.
+  If they differ, a resource can appear in a list but return "not found" when fetched, or the other way around.
 - Give bots, service accounts, and internal tokens the narrowest permission that does the job.
 - Do not pass `skip_authorization: true` for requests a user or bot makes, because it bypasses the policy checks, including custom roles.
   Reserve it for system-driven flows such as provisioning, and never derive it from user input.
@@ -526,6 +528,10 @@ For the structure, see the [testing guidelines](testing_guidelines.md).
 - Use `expect_allowed` and `expect_disallowed`.
   The `Gitlab/Authz/UsePolicyHelpers` cop enforces this.
 - Use real memberships and settings instead of stubbing `can?` or `Ability.allowed?`.
+- Exception: when a rule checks another permission on the same policy, such as `rule { can?(:read_code) }`,
+  use `stub_permissions(read_code: false)` instead of building the setup that permission needs.
+  This helper does not stub checks on other subjects, such as `@user.can?(:read_code, project)`.
+  Never stub the permission under test.
 
 ## Automated checks
 
diff --git a/doc/development/permissions/testing_guidelines.md b/doc/development/permissions/testing_guidelines.md
index 130948635..d96107400 100644
--- a/doc/development/permissions/testing_guidelines.md
+++ b/doc/development/permissions/testing_guidelines.md
@@ -85,11 +85,31 @@ If a policy delegates to another policy, check the delegation with `delegate_to`
 and test only the rules the policy adds.
 
 ```ruby
+it { expect(described_class).to delegate_to(ProjectPolicy) }
+```
+
+When you pass the policy class, the spec needs no setup.
+The matcher builds the subject from the model's factory with `build_stubbed`, and uses a `nil` user.
+
+Pass a policy instance instead when:
+
+- The default factory does not set the association the policy delegates to.
+- The subject has no factory, or the policy name does not match the model name.
+- The delegate depends on the subject, such as `delegate { @subject.container }`.
+
+`delegate_to` only checks which policy the subject delegates to. It doesn't evaluate
+any rules, so when you pass an instance, build the user and subject instead of creating them.
+
+```ruby
+let(:user) { build(:user) }
+let(:resource) { build(:ci_catalog_resource, project: build(:project)) }
+
+subject(:policy) { described_class.new(user, resource) }
+
 it { is_expected.to delegate_to(ProjectPolicy) }
 ```
 
-If the delegate depends on the subject, such as `delegate { @subject.container }`,
-check each possible delegate:
+For a delegate that depends on the subject, check each possible delegate:
 
 ```ruby
 it { expect(described_class.new(user, project_trigger)).to delegate_to(ProjectPolicy) }
@@ -104,6 +124,28 @@ Do not create extra users, projects, or memberships to fill out a role table.
 - Use `let_it_be` for records.
 - Reuse the users and projects from the shared policy contexts instead of creating new ones.
 
+### Rules that check another permission
+
+When a rule checks another permission on the same policy, such as `rule { can?(:download_code) }`,
+stub that permission with `stub_permissions` instead of building the setup it needs:
+
+```ruby
+describe 'read_repository_graphs' do
+  let(:current_user) { guest }
+
+  context 'when user cannot download_code' do
+    before do
+      stub_permissions(download_code: false)
+    end
+
+    it { expect_disallowed(:read_repository_graphs) }
+  end
+end
+```
+
+`stub_permissions` doesn't stub checks on other subjects, such as `@user.can?(:read_code, project)`.
+Never stub the permission under test.
+
 ### Specify the subject in every block
 
 Always define the subject explicitly inside each `describe` block using

permissions-rest-gpat

Source files (4)
SSOT diff since previous distillation (0598d6c1 → 2019a7ae)
diff --git a/doc/development/permissions/granular_access/rest_api_implementation_guide.md b/doc/development/permissions/granular_access/rest_api_implementation_guide.md
index 860b156a7..df8fe4562 100644
--- a/doc/development/permissions/granular_access/rest_api_implementation_guide.md
+++ b/doc/development/permissions/granular_access/rest_api_implementation_guide.md
@@ -330,6 +330,27 @@ The "granting access" assertion expects a real success response, so supply valid
 sure any resource the request path references exists. Reuse the `let` definitions and setup of the
 `describe` block that already tests the endpoint, and place the shared example inside that block.
 
+The comparison with a legacy non-member request runs only when `boundary_object` is a public
+project or group. The shared example skips it on a private or internal one. Your `describe` block
+might use a private project or group. If anonymous users can reach the `GET` endpoint on a public
+one, add a second invocation with a public `boundary_object`. Without it, a permission missing
+from `public_anonymous.yml` fails no spec:
+
+```ruby
+context 'when the project is public' do
+  before do
+    project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+  end
+
+  it_behaves_like 'authorizing granular token permissions', :read_pipeline_job do
+    let(:boundary_object) { project }
+    let(:request) do
+      get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", personal_access_token: pat)
+    end
+  end
+end
+```
+
 #### Boundary Object Mapping
 
 The `boundary_object` must match the `boundary_type`:

How this works

A scheduled CI job detects changes to doc/development/ files listed in .ai/principles/manifest.yml, then uses the GitLab Duo Agent Platform Workflow API to distill updated principles for the affected domains.

This MR was generated by https://gitlab.com/gitlab-org/gitlab/-/jobs/17684393710

Please review the checklist changes to ensure they accurately reflect the documentation updates.

Edited by Pedro Pombeiro

Merge request reports

Loading
Loading