Recruit Data Blog

  • はてなブックマーク

目次

データ推進室のsaka1です。

住まい領域と呼ばれる事業における、データ分析用の社内Webアプリケーションの開発・保守に携わっています。

リクルート社内ではさまざまなソースコード管理プラットフォームが採用されています。私の周辺では社内ネットワークにホスティングされたGitLabが利用されていることが多く、普段の開発はGitLab上で行っています。

この記事では、私が所属するチームで発生したGitLabの管理上の問題と、それを解決するために行なった活動について紹介していきます。

本記事は、データ推進室 Advent Calendar 2024 8日目の記事です

GitLabでどんな課題が起きたか

GitLabは、大まかにはGitHubと似たような機能を提供しますが、権限管理(どのユーザが何に対して何の権限を持つか)に関しては複雑な階層化を行うことができます。

しかし、私が関わるシステムでは、以前からアドホックな権限付与が行われており、誰に何の権限が付与されているのかが不明確な状況になっていました。現状の棚卸しを行うとともに、今後の管理方法を決める必要がありました。

この記事では、こうした状況を改善するために行った取り組みの概要を紹介します。最終的には権限管理ツールの話になりますが、途中でGitLabの権限管理モデルについても解説していきます。

GitLabの階層モデル

GitLabでは、個々のGitリポジトリのことをプロジェクトと呼びます。そして、プロジェクトをまとめる単位としてグループがあります。1つのグループには0個以上のプロジェクトが含まれます。

グループは親子関係を作ることもでき、グループの下にさらにグループを作成することで木構造を形成できます。

group
├── subgroup1
│   ├── project1
│   └── project2
└── subgroup2

このように、プロジェクトとグループの関係はファイルとディレクトリの関係に似ています。

ユーザが権限を得るためには、プロジェクトやグループのメンバーになる必要があります。この際にロールと呼ばれる権限レベルを設定します。ユーザが行える操作は、ロールによって決まります。

具体的なロールとしてはOwner、Maintainer、Developer、Reporter、Guestがあります。Ownerが最も強く、Guestは限られた権限しか持ちません(例外的にMinimal Accessというものもありますが省略します)。

参考リンク: https://docs.gitlab.com/ee/user/permissions.html

メンバー関係を矢印で表し、ロールを[Role]と書くと

project1  <-[Owner]- saka1
project2  <-[Reporter]- saka1

この例だと、saka1はproject1に関してOwner権限(なんでもできる)を持ち、project2に関してはReporter権限(Issueの作成など限られた操作しかできない)を持ちます。

ロールによる権限は、下位のプロジェクトやグループにも継承されます。ただし、下位のプロジェクトやグループでより強いロールで参加すると、そのロールが優先されます(逆に弱いロールで参加して権限を弱めることはできません)。

group  <-[Developer]- Alice
├── subgroup1
│   ├── project1
│   └── project2  <-[Owner]- Alice
└── subgroup2

この例だと、Aliceはgroup subgroup1 subgroup2 project1でDeveloper権限を持っている上で、project2ではOwner権限を持っています。

別の言い方をすると、ユーザが特定のプロジェクトやグループに持っている権限は、上位に向かって参加関係を探索した中で最も強いロールが適用されます。

GitLabの権限管理は強力だが複雑

ここまで説明してきたように、GitLabの権限管理は柔軟です。しかし、柔軟であるがゆえに、権限の付与方法は迷うことがあります。

たとえば、次のような疑問が生じます。

  • グループをどのように分割するのが良いか?
  • 分割したグループのどの階層で、誰にどんなロールを適用するか?
  • プロジェクトにユーザは直接参加してよいのか、それともユーザはグループ単位で権限を付与するべきなのか?

開発チームの状況によって正解は異なるかもしれませんが、いずれにせよ一貫したルールのもとで管理しないと、すぐに混乱してしまいます。

「XさんがAプロジェクトに付いている権限が弱すぎるんだけど、どこのグループで付与されているんだろう?」

個別の状況についてはWeb UIから検索できますが、全体の構造を把握するのはあまり簡単ではありません。

GitLabの構成管理ツールの自作

ここからは、前述の問題を解決するためのツールの開発について紹介していきたいと思います。

  • Pythonで実装しました。特に向いている言語というわけではないのですが、チームのスキルセット的に親和性が高かったため採用しています
  • 実装の一部をスニペットの形で示します。エッセンスはそのままですが、可読性のため、実際に書いた実装からは少し手を入れています(例えばエラー処理やロギングについては削っている箇所があります)

欲しいのは構成管理ツール

一般に、複雑なリソースの関係を管理する際、ソフトウェアエンジニアはしばしば構成管理ツールに頼ります。例えば、TerraformやAWS CDKなどが有名です。

GitLabでも同様に、グループ階層に対する権限付与の状況を設定ファイルのような形で記述し、その状態にGitLabの設定を収束させるツールがあれば、権限管理がしやすくなると考えました。

しかし、調べた限りだと手頃なツールは見つからなかったため、自作することにしました。

設定ファイル

設定記述はYAMLを採用することにしました。設定ファイルとしてメジャーで木構造の表現に向いているためです。グループ階層をkey/valueのネストで表すことにし、それぞれにプロジェクト、属性を生やすことができるように設計します。

具体例として、先の説明で使ったグループ階層とAliceに対する権限付与を、設定ファイルで書いてみます。

group:
  _membership:
    - {username: "Alice", role: "Developer"}
  subgroup1:
    $project1:
    $project2:
      _membership:
        - {username: "Alice", role: "Owner"}
  subgroup2:
  • プロジェクトは接頭辞に$を、属性には接頭辞_を付けました。YAMLはマッピングのkeyに使える記号が意外と少ないのですが、可読性を踏まえて$ _を選んでいます。
  • 有効な属性として、ここでは_membershipが使われています。プロジェクトやグループへの参加関係を定義したものです。

ツールの実装

筆者は構成管理ツールの類の実装をしたことがなかったので、手探りで(つまり適当に)実装しました。といってもそれほど複雑な実装が必要なわけではありません。単純な構造しか扱わないからです。

設定ファイル側とGitLab側で木構造の比較をしなければならないのは明らかなので、まず設定ファイル側を解析して「状態を収束させる目標となる木」を構築します。

そして、目標と現在の差分を計算し、差分を埋めるための操作を発行して目標と現在を一致させます。

構成記述の構築

まず木のノードを定義します。ここでいうUserは、ユーザアカウントというよりプロジェクト・グループへの参加関係を表すクラスにしたので、roleを持つようにしています。

@dataclass(frozen=True)
class User:
    username: str
    role: str

    # https://docs.gitlab.com/ee/api/members.html#roles
    ACCESS_LEVEL_MAP = {
        "Owner": 50,
        "Maintainer": 40,
        "Developer": 30,
        "Reporter": 20,
        "Guest": 10,
        "Minimal Access": 5,  # "available for the top-level group only"
    }

    def __post_init__(self) -> None:
        if self.role not in self.ACCESS_LEVEL_MAP:
            raise ValueError(f"Unknown role: {self.role}")

@dataclass
class Group:
    name: str
    parent: Optional["Group"] = None
    membership: List[User] = field(default_factory=list)
    subgroups: List["Group"] = field(default_factory=list)
    projects: List["Project"] = field(default_factory=list)


@dataclass(frozen=True)
class Project:
    name: str
    parent: Group
    membership: List[User] = field(default_factory=list)

YAMLパーサを通すとPythonのdictが得られるので、dictを再帰的に走査して組み立てていきます。

def _parse_group(group_name: str, group_content: dict[str, Any]) -> Group:
    group = Group(name=group_name)
    group_content = group_content or {}

    for key, value in group_content.items():
        # 子project
        if key.startswith("$"):
            project_name = key[1:]
            value = value or {}
            project = Project(
                name=project_name,
                parent=group,
                ignore=value.get("_ignore", False),
            )
            for user in value.get("_membership", []):
                project.membership.append(
                    User(username=user["username"], role=user["role"])
                )
            group.projects.append(project)

        # group属性
        elif key.startswith("_"):
            match key:
                case "_membership":
                    for user in value:
                        group.membership.append(
                            User(username=user["username"], role=user["role"])
                        )
        # 子group
        else:
            child_group = _parse_group(key, value)
            child_group.parent = group
            group.subgroups.append(child_group)

    return group

ここまでの実装を使うと、設定ファイルを木構造に変換できます。試しに動かしてみます。

config = yaml.safe_load(Path("config.yaml").read_text())
group = _parse_group("group", config["group"])
pprint(group)
Group(name='group',
      parent=None,
      membership=[User(username='Alice', role='Developer')],
      subgroups=[Group(name='subgroup1',
                       parent=...,
                       membership=[],
                       subgroups=[],
                       projects=[Project(name='project1',
                                         parent=...,
                                         membership=[],
                                         ignore=False),
                                 Project(name='project2',
                                         parent=...,
                                         membership=[User(username='Alice',
                                                          role='Owner')],
                                         ignore=False)],
                       ignore=False),
                 Group(name='subgroup2',
                       parent=...,
                       membership=[],
                       subgroups=[],
                       projects=[],
                       ignore=False)],
      projects=[],
      ignore=False)

木のトラバース

これから行うのは設定とGitLab側の状態の差分検出です。したがって、プロジェクト・グループ単位での比較が必要なので、グループを再帰的にトラバースする関数を用意しておきます。

def enumerate_tree(group: Group) -> Generator[Group | Project, None, None]:
    yield group
    for project in group.projects:
        yield project
    for subgroup in group.subgroups:
        yield from enumerate_tree(subgroup)

差分検出……の前にGitLab APIの紹介

さて、GitLab APIを叩いて設定ファイルの記述との差分を計算さえできれば、あとは差分を埋めるような操作をすればいいはずです。関係するAPIはこれらになります。

ここで、簡単にGitLabのAPI v4について紹介しておきます。APIはおおむね綺麗なREST APIになっています。例えば <API_URL>/groups からグループの一覧が取得でき、<API_URL>/groups/:id から特定のグループの情報を取得できます。

ここでいう :id は原則として整数で、リソース作成時にGitLabが割り当てた番号なのですが、リソースによっては :id にパス名を許すようになっています。パス名とは、グループの階層を %2F/のパーセントエンコード表現です)で区切ったものです。例えば先の例でいうproject1は、groupの子のsubgroup1の子のプロジェクトだったので

https://<API_URL>/projects/group%2Fsubgroup1%2Fproject1

がリソースのURLとなります。すなわち「深い階層にあるプロジェクトやグループであっても、そこまでのグループ名を全て知っていれば、APIを叩かずにURLを決定できる」ことを意味します。

GroupクラスやProjectクラスにparentを作っておいたのは、インスタンスに対応するリソース名を組み立てられるようにするためです(他の実装、例えばparse時に計算してdataclassに渡すといった実装もあり得ると思います)。

@dataclass(frozen=True)
class Project:
    name: str
    parent: Group

    def full_name_urlencoded(self) -> str:
        return "%2F".join([self.parent.full_name_urlencoded(), self.name])

その他のAPI仕様については変わった点はなさそうでした。今回は単純なAPIのクライアント実装を作ってそれを使うことにしたのですが、実装としては面白い部分ではないので紹介を省略します。

差分検出

必要な道具が揃ったので、差分を検出する実装が作れます。まず「差分(現在をどう変更すると目標になるか)」を表すクラスを作ります。どう変更するか(action)・ユーザ(user)・参加対象(member_of)の組で表現しました。

@dataclass(frozen=True)
class ModifyEvent:
    action: Literal["add", "remove", "update"]
    username: str
    access_level: Optional[int]
    member_of: Group | Project = field(
        repr=False
    )  # pretty-printが長くなりすぎるので非表示
    full_name_urlencoded: str = field(init=False)

    comment: Optional[str] = None

    @property
    def kind_member_of(self) -> Literal["groups", "projects"]:
        return "groups" if isinstance(self.member_of, Group) else "projects"

このように差分を一旦別のデータ構造に写しとるのは冗長で面倒な作業ですが、dry-runの実装を容易にしたり、テストがしやすくなったりと、メリットが大きいように感じました。

ModifyEventを使うとき、差分検出の実装は、目標を引数にとってModifyEventの集合を返す関数といえます。

def calc_diff_to_create_modify_events(
    base_group: Group, client: GitLabClient, logger: logging.Logger
) -> list[ModifyEvent]:
    events: list[ModifyEvent] = []
    for x in enumerate_tree(base_group):
        match x:
            case Group() | Project() as grouplike if grouplike.ignore:
                # do nothing
            case Group() as group:
                actual_group = client.get_group(group.full_name_urlencoded())
                actual_name_level: dict[str, int] = {
                    d["username"]: d["access_level"] for d in actual_group["members"]
                }
                desired_member = group.membership
                events.extend(
                    calc_diff(group, actual_name_level, desired_member, logger)
                )
            case Project() as project:
                actual_project = client.get_project(project.full_name_urlencoded())
                porject_actual_name_level: dict[str, int] = {
                    d["username"]: d["access_level"] for d in actual_project["members"]
                }
                desired_member = project.membership
                events.extend(
                    calc_diff(
                        project, porject_actual_name_level, desired_member, logger
                    )
                )
            case _:
                logger.error(f"[BUG] Unknown case: {x}")

    return events

トラバースをenumerate_treeに移譲したことと、構造的パターンマッチで素直な場合分けができているので、全体として見通しが良いコードになったように感じます。

差分適用

あとは list[ModifyEvent] を元にGitLab APIを叩くだけです。コードは単純ですがやや長いので一部分だけを示します。

def apply_modify_events(
    events: list[ModifyEvent], client: GitLabClient, logger: logging.Logger
):
    for event in events:
        match event:
            case ModifyEvent(
                action="add", username=username, access_level=access_level
            ) if access_level is not None:
                logger.info(
                    f"Add {username} to '{event.full_name_urlencoded}', access_level={access_level}"
                )
                add_result = client.add_grouplike_member(
                    entity_type=event.kind_member_of,
                    grouplike_full_path=event.full_name_urlencoded,
                    username=username,
                    access_level=access_level,
                )
                add_result.ensure_status_code(201)
            # remove, updateについても同様のcaseがある
            # ....

ここまでが全体の実装の雰囲気の紹介でした。もちろんツールとして動作させるためにはCLI部分やGitLabClientの実装も必要になるのですが、あまり面白いところがないので紹介を省略します。

構成管理ツールを自作してどうだったか?

一定の安心感が得られました。参加者関係の全体像をYAMLの形で把握できるのは便利でした。また、奇妙な参加者がいたり、ロールが期待通りでない場合を機械的に是正できるので、グループ以下の参加者関係がクリーンであることに自信がもてます。

一方で、自動化は何かを破壊してしまうリスクも含んでいます。リスクコントロールのために、以下のように条件を付けて利用し、段階的にリスクを踏むことにしました。

  • 最悪の場合でもチーム内での権限が喪失しないように、上位グループで最低限の権限を付与した状態でツールを試行する
  • ツールの適用を一部のグループ以下に留めて実績を積む、といった段階を踏む

実際ツールの開発中に誤動作があり、一部のロールに設定ミスを起こす等のトラブルが一時的に発生しましたが、重大なトラブルとまではならずに済んでいます。

まとめ

この記事ではGitLabの権限管理の仕組みを紹介し、それを構成管理する自作ツールの実装について紹介しました。

GitLabには、プロジェクトの承認ルールなど、構成管理をしたい設定項目が他にもいくつかあります。そういったものもYAMLに載せるようにすれば更に便利なツールになってくれるのかもしれません。

saka1

ソフトウェアエンジニア

saka1

2023年リクルート入社後、Webバックエンドやデータエンジニアの領域のお仕事をしています。