Secrets Manager 아키텍처
GitLab v19.5요약
Secrets Manager는 PostgreSQL에 데이터 모델을 저장하고, OpenBao 네임스페이스 레이아웃을 구성하며, 프로젝트, 그룹, 파이프라인의 경로를 계산합니다. SecretsManagement 아래에는 두 종류의 Ruby 객체가 있습니다.
Secrets Manager는 PostgreSQL에 데이터 모델을 저장하고, OpenBao 네임스페이스 레이아웃을 구성하며, 프로젝트, 그룹, 파이프라인의 경로를 계산합니다. 상태 전환과 부수 효과는 Secrets Manager 상태와 부수 효과를 참고합니다. 자격과 과금은 Secrets Manager 이행 및 자격을 참고합니다.
데이터 모델#
SecretsManagement 아래에는 두 종류의 Ruby 객체가 있습니다.
| 종류 | 기본 클래스 | 예시 | 스토리지 |
|---|---|---|---|
| Secrets manager 레코드 | ApplicationRecord |
ProjectSecretsManager, GroupSecretsManager |
PostgreSQL |
| 시크릿 및 권한 객체 | ActiveModel::Model |
ProjectSecret, GroupSecret, ProjectSecretsPermission, GroupSecretsPermission |
OpenBao이며 데이터베이스 테이블이 아님 |
ProjectSecret과 GroupSecret에는 find, where, save, 데이터베이스 기반 유효성 검사가 없습니다.
이 객체는 OpenBao API 응답으로 만들어집니다.
감사 이벤트는 시크릿 객체가 아니라 항상 Project 또는 Group 레코드를 대상으로 해야 합니다.
Secrets manager 테이블#
SecretsManagement::BaseSecretsManager는 추상 ApplicationRecord 클래스입니다.
status 상태 머신과 공유 헬퍼 모듈을 정의합니다.
구체적인 하위 클래스 두 개가 테이블 두 개에 대응합니다.
| 모델 | 테이블 | 부모 연관 |
|---|---|---|
SecretsManagement::ProjectSecretsManager |
project_secrets_managers |
belongs_to :project |
SecretsManagement::GroupSecretsManager |
group_secrets_managers |
belongs_to :group |
Project와 Group은 각각 반대쪽 연관을 노출합니다.
# ee/app/models/ee/project.rb
has_one :secrets_manager, class_name: '::SecretsManagement::ProjectSecretsManager', inverse_of: :project
# ee/app/models/ee/group.rb
has_one :secrets_manager, class_name: '::SecretsManagement::GroupSecretsManager', inverse_of: :group
두 테이블 모두 organization_id와 root_namespace_id를 저장하며, 이 값은 생성 시 부모의 ID로 설정됩니다.
두 테이블에는 여전히 namespace_path, project_path, group_path, root_namespace_path 칼럼도 있습니다.
이 칼럼은 모델에서 ignore_column으로 선언되어 있으며 더 이상 읽지 않습니다.
경로 구성은 실시간으로 이루어지며, 네임스페이스 경로 구성에서 설명합니다.
이 칼럼은 이후 릴리스에서 제거할 예정입니다.
기타 Secrets manager 테이블#
Secrets Manager는 보조 데이터를 위해 테이블 다섯 개를 더 사용합니다.
| 모델 | 테이블 | 용도 |
|---|---|---|
RecoveryKey |
secrets_management_recovery_keys |
암호화된 OpenBao 복구 키 |
ProjectSecretRotationInfo |
secret_rotation_infos |
프로젝트 시크릿의 로테이션 알림 추적 |
GroupSecretRotationInfo |
group_secret_rotation_infos |
그룹 시크릿의 로테이션 알림 추적 |
ProjectSecretsManagerMaintenanceTask |
project_secrets_manager_maintenance_tasks |
프로젝트의 프로비저닝 및 프로비저닝 해제 작업 |
GroupSecretsManagerMaintenanceTask |
group_secrets_manager_maintenance_tasks |
그룹의 프로비저닝 및 프로비저닝 해제 작업 |
유지 관리 작업과 삭제 트리거가 함께 동작하는 방식은 상태 전환의 부수 효과를 참고합니다.
모델 헬퍼 모듈#
BaseSecretsManager는 두 범위가 그대로 사용하는 공유 헬퍼 모듈을 포함합니다.
각 하위 클래스는 범위별 메서드를 추가하거나 공유 메서드를 재정의하는 자체 헬퍼 모듈도 포함합니다.
| 계층 | 모듈 | 파일 | 정의하는 항목 |
|---|---|---|---|
| 공유 | SecretsManagement::SecretsManagers::PipelineHelper |
ee/app/models/secrets_management/secrets_managers/pipeline_helper.rb |
ci_secrets_mount_path, ci_data_path, ci_full_path, ci_metadata_full_path, detailed_metadata_path, ci_auth_mount, ci_auth_role, ci_jwt |
| 공유 | SecretsManagement::SecretsManagers::UserHelper |
ee/app/models/secrets_management/secrets_managers/user_helper.rb |
user_auth_mount, user_auth_role, policy_name_for_principal, user_path, role_path |
| 프로젝트 | SecretsManagement::ProjectSecretsManagers::PipelineHelper |
ee/app/models/secrets_management/project_secrets_managers/pipeline_helper.rb |
ci_auth_path, pipeline_auth_cel_program, ci_secrets_mount_full_path, ci_policy_name과 그 변형 |
| 프로젝트 | SecretsManagement::ProjectSecretsManagers::UserHelper |
ee/app/models/secrets_management/project_secrets_managers/user_helper.rb |
user_auth_cel_program |
| 그룹 | SecretsManagement::GroupSecretsManagers::PipelineHelper |
ee/app/models/secrets_management/group_secrets_managers/pipeline_helper.rb |
ci_auth_path, ci_secrets_mount_full_path, ci_policy_name_for_environment, pipeline_auth_cel_program |
| 그룹 | SecretsManagement::GroupSecretsManagers::UserHelper |
ee/app/models/secrets_management/group_secrets_managers/user_helper.rb |
user_auth_cel_program |
헬퍼 메서드를 추가할 때 두 범위에 같은 동작이 필요하면 공유 모듈에 넣습니다. 범위에 고유한 로직이 필요하면(예: 다른 CEL 프로그램이나 다른 정책 이름 규칙) 프로젝트 또는 그룹 모듈에 넣습니다.
OpenBao 네임스페이스 계층#
Secrets Manager를 활성화한 모든 프로젝트 또는 그룹은 자체적인 3단계 OpenBao 네임스페이스를 갖습니다.
| 단계 | 세그먼트 | 구성 근거 |
|---|---|---|
| 1 | org_<organization_id> |
부모의 organization_id |
| 2 | group_<root_namespace_id> |
부모의 최상위 그룹 ID |
| 3 | project_<project_id> 또는 group_<group_id> |
프로젝트 또는 그룹 자체의 ID |
GitLab 하위 그룹 계층은 3단계에서 평탄화됩니다.
깊게 중첩된 하위 그룹에 있는 프로젝트도 하위 그룹마다 세그먼트를 두지 않고
org_<X>/group_<root>/project_<P>를 받습니다.
최상위 그룹 자체의 Secrets Manager는 2단계와 3단계 모두에서 자신의 ID를 다시 사용합니다. 예를 들면
org_1/group_5/group_5입니다.
Secrets Manager는 사용자 네임스페이스를 지원하지 않습니다.
네임스페이스 경로 구성#
경로는 저장된 칼럼에서 읽지 않고 호출 시점에 실시간으로 계산합니다.
ProjectSecretsManager와 GroupSecretsManager는 모두 1단계와 2단계 빌더가 있는 공유 PathBuilder 모듈
(ee/app/models/secrets_management/path_builder.rb)을 포함합니다.
def build_org_path(organization_id)
"org_#{organization_id}"
end
def build_root_namespace_path(root_namespace_id)
"group_#{root_namespace_id}"
end
각 모델은 자체 3단계 세그먼트를 계산하고 세 단계를 모두 연결합니다.
# ProjectSecretsManager
def org_path
self.class.build_org_path(project.organization_id)
end
def namespace_path
self.class.build_root_namespace_path(project.root_ancestor.id)
end
def project_path
self.class.build_project_path(project.id)
end
def full_project_namespace_path
[org_path, namespace_path, project_path].join('/')
end
GroupSecretsManager의 대응 메서드는 group.organization_id, group.root_ancestor.id, group.id로
full_group_namespace_path를 만듭니다.
경로가 실시간 부모 레코드에 의존하므로, 부모 프로젝트나 그룹이 삭제된 뒤에 실행되는 코드는 이 메서드를 호출할 수 없습니다.
그러한 코드는 다른 곳에 저장된 ID를 사용해야 하며, 이는 변경 사항 체크리스트에서 설명합니다.
범위 격리와 수준 간 액세스#
각 프로젝트, 하위 그룹, 최상위 그룹은 각각 자체 OpenBao 네임스페이스입니다. 네임스페이스 상속은 없습니다. 로그인은 하나의 네임스페이스로 범위가 한정되며 해당 네임스페이스의 시크릿만 읽습니다. 프로젝트의 네임스페이스가 부모 그룹의 시크릿을 자동으로 노출하지는 않습니다.
부모 그룹 시크릿에 대한 CI 액세스#
CI는 상속이 아니라 여러 번의 로그인을 통해 부모 그룹의 시크릿을 읽습니다.
프로젝트 파이프라인은 자체 프로젝트 네임스페이스에 더해 그룹 자체의 네임스페이스 인증 마운트에도 직접 인증합니다.
그룹의 파이프라인 CEL 프로그램은 프로젝트의 파이프라인 JSON Web Token(JWT)을 받아
project_group_ids 클레임으로 해당 프로젝트가 그룹의 하위 항목인지 확인합니다.
이 클레임은 JWT를 만들 때 설정됩니다.
# ee/lib/secrets_management/pipeline_jwt.rb
def project_group_ids
source_project.group&.self_and_ancestors&.pluck(:id)&.map(&:to_s) || []
end
그다음 그룹의 파이프라인 CEL 프로그램이 이 클레임에 그룹 자체의 ID가 포함되어 있는지 확인합니다.
# ee/app/models/secrets_management/group_secrets_managers/pipeline_helper.rb
{ name: "project_gids",
expression: %q(('project_group_ids' in claims) ? claims['project_group_ids'] : []) }
이 방식으로 부모 그룹의 시크릿을 가져오는 데 프로젝트 자체의 Secrets Manager는 필요하지 않습니다. 그룹은 프로젝트의 그룹이거나 그 상위 그룹이어야 합니다. 해당 Secrets Manager가 활성 상태여야 하고, 그룹에서 Secrets Manager를 사용할 수 있어야 하며, 최상위 그룹의 자격이 직접 읽기를 허용해야 합니다.
CI가 아닌 API 경로에는 수준 간 액세스가 없음#
직접 액세스 토큰 경로는 토큰 자체의 project_id 또는 group_id를 로그인하는 네임스페이스와 대조해 검증합니다.
프로젝트 범위 토큰은 해당 프로젝트의 시크릿만 읽습니다.
그룹 범위 토큰은 해당 그룹의 시크릿만 읽습니다.
CI 밖에서 그룹 수준 시크릿을 읽으려면 클라이언트가 그룹 범위 토큰을 사용합니다.
이 경로에는 project_group_ids에 해당하는 것이 없습니다.
GitLab이 러너에 보내는 정보#
EE::Ci::BuildRunnerPresenter는 작업 페이로드에 인증 경로와 시크릿 마운트 경로를 담아 러너에 보냅니다.
러너가 직접 구성하도록 네임스페이스 형식을 보내지는 않습니다.
def gitlab_projects_secrets_manager_server(project_secrets_manager)
{
'url' => SecretsManagement::ProjectSecretsManager.server_url,
'inline_auth' => {
'jwt' => project_secrets_manager.ci_jwt(self),
'role' => project_secrets_manager.ci_auth_role,
'path' => project_secrets_manager.ci_auth_path
}.compact
}
end
def project_secrets_manager_payload(secret)
return {} unless project_secrets_manager_available?
project_secrets_manager = SecretsManagement::ProjectSecretsManager.find_by_project_id(project.id)
return {} unless project_secrets_manager&.active?
{
'engine' => { 'name' => "kv-v2", 'path' => project_secrets_manager.ci_secrets_mount_full_path },
'path' => project_secrets_manager.sanitized_ci_data_path(secret['gitlab_secrets_manager']['name']),
'field' => "value",
'server' => gitlab_projects_secrets_manager_server(project_secrets_manager)
}
end
그룹의 대응 메서드인 group_secrets_manager_payload는 GroupSecretsManager#ci_auth_path와
ci_secrets_mount_full_path로 같은 형태를 만듭니다.
러너에는 하드코딩된 경로 형식이 없으므로, OpenBao 네임스페이스가 GitLab이 계산한 것과 일치하기만 하면
GitLab이 네임스페이스 경로를 만드는 방식을 바꿔도 러너에는 영향이 없습니다.
OpenBao의 시크릿 저장 경로#
네임스페이스 안에서 시크릿은 고정 경로에 마운트된 key-value 버전 2(KV-v2) 엔진을 사용합니다.
# ee/app/models/secrets_management/secrets_managers/pipeline_helper.rb
SECRETS_MOUNT_PATH = "secrets/kv"
DATA_ROOT = "explicit"
이 상수로 읽기와 쓰기에 사용하는 전체 경로를 구성합니다.
| 경로 종류 | 형태 |
|---|---|
| 마운트(네임스페이스 포함) | <namespace>/secrets/kv |
| 시크릿 데이터 | secrets/kv/data/explicit/<secret_name> |
| 메타데이터 | secrets/kv/metadata/explicit/<secret_name> |
| 상세 메타데이터 | secrets/kv/detailed-metadata/explicit/<secret_name> |
시크릿의 KV 경로는 네임스페이스 안에서 시크릿 이름을 키로 합니다.
environment와 branch는 별도의 경로가 아닙니다.
같은 KV 항목의 사용자 지정 메타데이터로 저장됩니다.
시크릿 속성#
ProjectSecret과 GroupSecret은 모두 BaseSecret에서 공유 속성을 상속합니다. name,
description, environment, rotation_info, metadata_version과 시크릿 상태를 계산하는 데 사용하는 생성 및 업데이트
타임스탬프 쌍이며, 이는 결합될 수 있는 상태에서 설명합니다.
| 모델 | 추가 속성 |
|---|---|
ProjectSecret |
project, branch |
GroupSecret |
group, protected |
이름 유효성 검사#
시크릿 모델은 고정 패턴과 길이 제한으로 각 이름을 검증합니다.
SECRET_NAME_FORMAT = /\A[a-zA-Z0-9_]+\z/
validates :name,
presence: true,
length: { maximum: 255 },
format: { with: SECRET_NAME_FORMAT, message: "can contain only letters, digits and '_'." }
시크릿 이름에는 영문자, 숫자, 밑줄만 사용할 수 있으며 최대 255자입니다. 이름으로 시크릿을 읽는 서비스는 경로 인젝션을 방지하기 위해 OpenBao를 호출하기 전에 이름을 검증해야 합니다.
시크릿 제한#
시크릿 제한은 애플리케이션 설정이며 ApplicationSetting의 secrets_manager_settings JSON 칼럼에 저장됩니다.
| 설정 | 기본값 | 의미 |
|---|---|---|
project_secrets_limit |
100 | 프로젝트당 최대 시크릿 수입니다. 0은 무제한을 뜻합니다. |
group_secrets_limit |
500 | 그룹당 최대 시크릿 수입니다. 0은 무제한을 뜻합니다. |
각 Secrets Manager 모델은 Gitlab::CurrentSettings를 통해 자체 제한을 읽습니다.
# ProjectSecretsManager
def secrets_limit
Gitlab::CurrentSettings.project_secrets_limit || DEFAULT_SECRETS_LIMIT
end