Consider admin mode for agent user access authorizations
- Please check this box if this contribution uses AI-generated content (including content generated by GitLab Duo features) as outlined in the GitLab DCO & CLA. As a benefit of being a GitLab Community Contributor, you can request access to GitLab Duo.
What does this MR do and why?
Clusters::Agents::Authorizations::UserAccess::Finder now returns a project's agent authorizations for an instance administrator in admin mode.
Today Project.userAccessAuthorizedAgents returns an empty list for an administrator who is not a member of the project, while a user with the Developer role gets the expected list. Both scopes behind the finder resolve authorizations from membership alone, ProjectAuthorization.for_user and GroupAuthorization.for_user, so an administrator without an explicit membership matches neither. The field is authorized with :read_cluster_agent, which an administrator passes, so the query succeeds and returns no data instead of a permission error.
The finder now skips both membership scopes for an administrator in admin mode, and reports the access level of an owner, the same as ProjectTeam#max_member_access and Group#max_member_access do. With admin mode off, nothing changes.
Two details worth a look:
- The admin scope selects
access_level, because EE's impersonation payload reads that column and a scope without it breaks the proxy with aNoMethodError. GroupAuthorization.for_projectnow filters ongroup_idinstead of on theall_groups_with_membershipCTE, so it works withoutfor_user. The filter is equivalent, because the CTE is joined on that column, andgroup_idis indexed.
Scope of the change
The finder has six callers, so the fix reaches more than the GraphQL field:
Clusters::Agents::AuthorizeProxyUserService, i.e. the KAS user access proxyEnvironments::CreateServiceandEnvironments::UpdateService, which today reject an administrator who sets a cluster agent on an environmentDeployments::UpdateEnvironmentServiceClusters::Agent#user_access_authorized_for?, which feeds:read_cluster_agent
All six have the same bug, so the fix belongs in the finder. This is not a privilege escalation: an administrator can already add themselves as an owner of any group or project and reach the same agents.
References
- Issue: #499364 (closed)
Database review
No migration and no schema change. One query changes and two are new. All three filter on an indexed column, index_agent_user_access_on_group_id or index_agent_user_access_on_project_id.
Query plans are missing, because I have no production-sized dataset. Can a database reviewer run the three queries on Postgres.ai, or point me at a way to get plans as a community contributor?
Group authorizations for a member, changed
Only the final WHERE changed, from the all_groups_with_membership CTE output to the base table. The two are equivalent, because the CTE is joined on agent_user_access_group_authorizations.group_id, and the base table column is indexed.
Before
WITH "groups_with_direct_membership" AS MATERIALIZED (
SELECT namespaces.id AS id, members.access_level AS access_level
FROM "namespaces"
INNER JOIN "members" ON "namespaces"."id" = "members"."source_id"
LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
WHERE "namespaces"."type" = 'Group' AND "members"."user_id" = 1 AND (access_level >= 10)
AND "members"."state" = 0 AND "members"."type" = 'GroupMember'
AND "members"."source_type" = 'Namespace'
AND (("members"."user_id" IS NULL AND "members"."invite_token" IS NOT NULL)
OR "users"."state" = 'active')
AND "members"."requested_at" IS NULL AND (members.access_level > 5)
AND "members"."access_level" >= 30
), "all_groups_with_membership" AS MATERIALIZED (
SELECT namespaces.id AS id, groups_with_direct_membership.access_level AS access_level
FROM "namespaces"
INNER JOIN groups_with_direct_membership
ON namespaces.traversal_ids @> ARRAY[groups_with_direct_membership.id]
WHERE "namespaces"."type" = 'Group'
)
SELECT DISTINCT ON (id) agent_user_access_group_authorizations.*,
all_groups_with_membership.access_level AS access_level
FROM "agent_user_access_group_authorizations"
INNER JOIN all_groups_with_membership
ON all_groups_with_membership.id = agent_user_access_group_authorizations.group_id
INNER JOIN "cluster_agents"
ON "cluster_agents"."id" = "agent_user_access_group_authorizations"."agent_id"
INNER JOIN "projects" ON "projects"."id" = "cluster_agents"."project_id"
WHERE "all_groups_with_membership"."id" = 22
ORDER BY id, access_level DESCAfter
WITH "groups_with_direct_membership" AS MATERIALIZED (
SELECT namespaces.id AS id, members.access_level AS access_level
FROM "namespaces"
INNER JOIN "members" ON "namespaces"."id" = "members"."source_id"
LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
WHERE "namespaces"."type" = 'Group' AND "members"."user_id" = 1 AND (access_level >= 10)
AND "members"."state" = 0 AND "members"."type" = 'GroupMember'
AND "members"."source_type" = 'Namespace'
AND (("members"."user_id" IS NULL AND "members"."invite_token" IS NOT NULL)
OR "users"."state" = 'active')
AND "members"."requested_at" IS NULL AND (members.access_level > 5)
AND "members"."access_level" >= 30
), "all_groups_with_membership" AS MATERIALIZED (
SELECT namespaces.id AS id, groups_with_direct_membership.access_level AS access_level
FROM "namespaces"
INNER JOIN groups_with_direct_membership
ON namespaces.traversal_ids @> ARRAY[groups_with_direct_membership.id]
WHERE "namespaces"."type" = 'Group'
)
SELECT DISTINCT ON (id) agent_user_access_group_authorizations.*,
all_groups_with_membership.access_level AS access_level
FROM "agent_user_access_group_authorizations"
INNER JOIN all_groups_with_membership
ON all_groups_with_membership.id = agent_user_access_group_authorizations.group_id
INNER JOIN "cluster_agents"
ON "cluster_agents"."id" = "agent_user_access_group_authorizations"."agent_id"
INNER JOIN "projects" ON "projects"."id" = "cluster_agents"."project_id"
WHERE "agent_user_access_group_authorizations"."group_id" = 22
ORDER BY id, access_level DESCProject authorizations for an administrator in admin mode, new
Before
No query. An administrator ran the membership query, which is unchanged and matched nothing.
After
SELECT agent_user_access_project_authorizations.*, 50 AS access_level
FROM "agent_user_access_project_authorizations"
INNER JOIN "cluster_agents"
ON "cluster_agents"."id" = "agent_user_access_project_authorizations"."agent_id"
INNER JOIN "projects" ON "projects"."id" = "cluster_agents"."project_id"
WHERE "agent_user_access_project_authorizations"."project_id" = 1Group authorizations for an administrator in admin mode, new
Before
No query. An administrator ran the membership query above, which matched nothing.
After
SELECT agent_user_access_group_authorizations.*, 50 AS access_level
FROM "agent_user_access_group_authorizations"
INNER JOIN "cluster_agents"
ON "cluster_agents"."id" = "agent_user_access_group_authorizations"."agent_id"
INNER JOIN "projects" ON "projects"."id" = "cluster_agents"."project_id"
WHERE "agent_user_access_group_authorizations"."group_id" = 22How to set up and validate locally
-
Register a user access authorization for an agent on a second project:
agent = Clusters::Agent.first deployment_project = Project.find_by_full_path('group/deployment-project') Clusters::Agents::Authorizations::UserAccess::RefreshService.new( agent, config: { 'user_access' => { 'access_as' => { 'agent' => {} }, 'projects' => [{ 'id' => deployment_project.full_path }] } } ).execute -
Sign in as an instance administrator who is not a member of
deployment-project, and enable admin mode. -
Run the query in GraphiQL:
query { project(fullPath: "group/deployment-project") { userAccessAuthorizedAgents { nodes { agent { name project { fullPath } } } } } } -
The agent is listed. Leave admin mode, run the query again, and the result is empty.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
The 11 new spec examples fail on master and pass with this change. The changelog commit has no EE: true trailer: it touches ee/, but the only file there is a spec, and the fix ships to CE.