Managing GitLab Permissions as Code with GitLabForm
A while back I wrote about a small script I built this to remove duplicate members in GitLab groups and repositories. It did its job, but it was a patch on a deeper problem: the more groups and projects we had to manage, the harder it became to keep track of who has access to what. Authorizing a single user is a few clicks. Doing it consistently across dozens of subgroups, while keeping branch protection and merge request rules in sync, is not.
What I really wanted was to describe the desired state once and let a tool reconcile reality against it. Turns out that tool already exists, and it is called GitLabForm.
What GitLabForm Is
GitLabForm is a configuration-as-code tool for GitLab. You describe your groups, projects and their settings in a YAML file, and GitLabForm uses the API to make your instance match the configuration definition from that file.
It authenticates with a personal or group access token (with the api scope), reads a config.yml by default, and applies whatever you point it at. Everything below lives in that single file.
Managing User Permissions
This is the part I care about most, so let us start there. Here is a shortened, anonymized version of an config example:
1---
2config_version: 4
3
4gitlab:
5 url: https://gitlab.example.com
6
7_defaults:
8 group_members: &default_members
9 inherit: false
10 enforce: true
11 keep_bots: true
12
13projects_and_groups:
14 #####################
15 ## GROUP DEFINITIONS
16 #####################
17 acme/teams/platform/*:
18 group_members:
19 <<: *default_members
20 users: &team_platform
21 alice_smith: { access_level: owner }
22 bob_jones: { access_level: owner }
23 jon_smith: { access_level: maintainer }
24
25 acme/teams/partners/*:
26 group_members:
27 <<: *default_members
28 users:
29 vendor-a.charlie.fox: { access_level: developer }
30 vendor-a.dana.lee: { access_level: developer }
31 vendor-b.erin.cole: { access_level: developer }
32
33 ###############
34 ## PERMISSIONS
35 ###############
36 acme/*:
37 group_members:
38 enforce: true
39 keep_bots: true
40 groups:
41 acme/teams/partners: { group_access: developer }
42 users: *team_platform
43 branches:
44 main: &branch_protection
45 protected: true
46 push_access_level: developer
47 merge_access_level: developer
48 unprotect_access_level: maintainer
49 allow_force_push: false
50 develop: *branch_protection
51 merge_requests_approvals:
52 disable_overriding_approvers_per_merge_request: true
53 merge_requests_author_approval: false
54 merge_requests_approval_rules:
55 default:
56 approvals_required: 1
57 name: "Any member"
58 rule_type: any_approver
59 enforce: true
60
61 acme/internal/secret-store:
62 members:
63 <<: *default_members
64 users:
65 vendor-a.charlie.fox: { access_level: reporter }
(ChatGPT gave me cool filler names for this part 😎.)
There is a lot going on here, so let me try to explain this to you:
The _defaults block with the &default_members anchor lets me write the membership rules once and reuse them everywhere with <<: *default_members, so I don’t repeat myself in every block. Some notable configuration options in our use case are:
inherit: falsekeeps things explicit. The list you see is the full picture, nothing is pulled in from a parent group.enforce: trueis what makes this declarative. Anyone added by hand in the UI who isn’t on the list gets removed on the next run. No more drift.keep_bots: truespares service accounts and bots from that cleanup, since those tend to power pipelines across several repositories.
The rest reads pretty much how it looks. Usernames are the keys and the access levels (owner, maintainer, developer, reporter, guest) are GitLab’s usual roles that you can specify.
The one thing worth pointing out is group_members versus members. One applies to a group (and with a * wildcard, everything below it), the other to a single project. And instead of listing people one by one, you can hand access to a whole group with the groups key. I use this for external 3rd party organizations for example.
My favorite part is the second anchor, &team_platform. I define the platform team once and reuse that list with *team_platform wherever those people need access.
This is exactly the duplication problem my old cleanup script was fighting. With enforce: true, those duplicates never get a chance to accumulate in the first place.
More Than Just User Permissions
If you look back at the acme/* block, you will notice it does not stop at members. In the same config file it also configures:
- Branch protection for
mainanddevelop, again sharing a single&branch_protectionanchor so both branches follow identical rules. - Merge request approval settings, like preventing authors from approving their own MRs.
- Approval rules, requiring at least one approver before anything merges.
And that is just a slice of what GitLabForm can manage. The tool covers a broad surface of GitLab resources, including:
- CI/CD variables and job token scope
- Webhooks and deploy keys
- Labels and badges
- Push rules and protected environments
- Pipeline schedules
- Project security settings, archiving and transfers
- Group LDAP and SAML links
It even supports raw parameter passing to the GitLab API, so you can usually configure newer GitLab features without waiting for the tool to add explicit support for them. That is a pretty cool feature due to the version drift that we sometimes experience in certain environments.
Running It
Using and testing this from local is fine for a first run, but the real value comes from running it automatically so reality cannot drift away from the config. To make that reusable across our repositories, we wrapped GitLabForm in a small GitLab CI/CD component (which is a pretty cool feature in GitLab):
1spec:
2 inputs:
3 name:
4 description: "Define a suffix for this job"
5 default: "0"
6 stage:
7 description: "Define the stage"
8 default: config
9 image:
10 description: "Container image with GitLabForm installed"
11 default: "registry.example.com/gitlabform:$TAG"
12 target:
13 description: "GitLabForm target groups or project"
14 default: "ALL_DEFINED"
15 arguments:
16 description: "GitLabForm arguments"
17 default: ""
18 allow_failure:
19 description: "Allow pipeline to fail if errors or warnings occur"
20 type: boolean
21 default: false
22---
23gitlabform-$[[ inputs.name ]]:
24 stage: $[[ inputs.stage ]]
25 image: $[[ inputs.image ]]
26 script:
27 - gitlabform $[[ inputs.arguments ]] $[[ inputs.target ]]
28 allow_failure: $[[ inputs.allow_failure ]]
The component just parametrizes everything. The target defaults to ALL_DEFINED, the image points to a prebuilt gitlabform container image, and arguments lets you slip in flags like --noop for a merge request pipeline that only reports the diff. Including it somewhere then looks like this:
1include:
2 - component: gitlab.example.com/tooling/gitlabform-component/gitlabform@5
3 inputs:
4 arguments: "--noop"
GITLAB_TOKEN and GITLAB_URL come from the project’s CI/CD variables, so the job has everything it needs.
Conclusion
The initial draft and “spring cleaning” coming from a manual configured GitLab instance that got messier over years was hard and took some time. It helped with removing old members that sometimes were not even in the company anymore and cleaned up some old and unused permissions. What sold me the most, is that now other members can add additional users for their project and a smaller group (defined via CODEOWNERS), can review the merge request and merge them. Now, we have a single source of truth and with that a good starting point for potential audit points.
Sources
GitLabForm - https://gitlabform.github.io/gitlabform/
GitLab access token scopes - https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html
GitLab CI/CD components - https://docs.gitlab.com/ee/ci/components/
Remove Member Duplications in GitLab - https://xfuture-blog.com/posts/remove-member-duplications-in-gitlab