Daniel's blog

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:

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:

And that is just a slice of what GitLabForm can manage. The tool covers a broad surface of GitLab resources, including:

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

← Previous Post

|

Next Post →