Secrets Manager architecture

Secrets Manager stores a data model in PostgreSQL, builds an OpenBao namespace layout, and computes paths for projects, groups, and pipelines. For state transitions and side effects, see Secrets Manager states and side effects. For entitlement and billing, see Secrets Manager fulfillment and entitlement.

Data model

Two kinds of Ruby objects exist under SecretsManagement.

KindBase classExamplesStorage
Secrets manager recordsApplicationRecordProjectSecretsManager, GroupSecretsManagerPostgreSQL
Secret and permission objectsActiveModel::ModelProjectSecret, GroupSecret, ProjectSecretsPermission, GroupSecretsPermissionOpenBao, not a database table

ProjectSecret and GroupSecret have no find, where, save, or database-backed validations. They are built from OpenBao API responses. An audit event must target the Project or Group record, never the secret object.

Secrets manager tables

SecretsManagement::BaseSecretsManager is an abstract ApplicationRecord class. It defines the status state machine and shared helper modules. Two concrete subclasses map to two tables.

ModelTableParent association
SecretsManagement::ProjectSecretsManagerproject_secrets_managersbelongs_to :project
SecretsManagement::GroupSecretsManagergroup_secrets_managersbelongs_to :group

Project and Group each expose the reverse side:

ruby
# 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

Both tables store organization_id and root_namespace_id, set on create from the parent’s own IDs. Both tables also still have namespace_path, project_path, group_path, and root_namespace_path columns. These columns are declared with ignore_column on the models and are no longer read. Path building is live, described in Namespace path construction. The columns are scheduled for removal in a later release.

Other secrets manager tables

Secrets Manager also uses five more tables for supporting data.

ModelTablePurpose
RecoveryKeysecrets_management_recovery_keysEncrypted OpenBao recovery keys
ProjectSecretRotationInfosecret_rotation_infosRotation reminder tracking for project secrets
GroupSecretRotationInfogroup_secret_rotation_infosRotation reminder tracking for group secrets
ProjectSecretsManagerMaintenanceTaskproject_secrets_manager_maintenance_tasksProvision and deprovision tasks for a project
GroupSecretsManagerMaintenanceTaskgroup_secrets_manager_maintenance_tasksProvision and deprovision tasks for a group

For how maintenance tasks and the destroy trigger work together, see Side effects on state transitions.

Model helper modules

BaseSecretsManager includes shared helper modules that both scopes use as is. Each subclass also includes its own helper modules, which add scope-specific methods or override shared ones.

LayerModuleFileDefines
SharedSecretsManagement::SecretsManagers::PipelineHelperee/app/models/secrets_management/secrets_managers/pipeline_helper.rbci_secrets_mount_path, ci_data_path, ci_full_path, ci_metadata_full_path, detailed_metadata_path, ci_auth_mount, ci_auth_role, ci_jwt
SharedSecretsManagement::SecretsManagers::UserHelperee/app/models/secrets_management/secrets_managers/user_helper.rbuser_auth_mount, user_auth_role, policy_name_for_principal, user_path, role_path
ProjectSecretsManagement::ProjectSecretsManagers::PipelineHelperee/app/models/secrets_management/project_secrets_managers/pipeline_helper.rbci_auth_path, pipeline_auth_cel_program, ci_secrets_mount_full_path, ci_policy_name and its variants
ProjectSecretsManagement::ProjectSecretsManagers::UserHelperee/app/models/secrets_management/project_secrets_managers/user_helper.rbuser_auth_cel_program
GroupSecretsManagement::GroupSecretsManagers::PipelineHelperee/app/models/secrets_management/group_secrets_managers/pipeline_helper.rbci_auth_path, ci_secrets_mount_full_path, ci_policy_name_for_environment, pipeline_auth_cel_program
GroupSecretsManagement::GroupSecretsManagers::UserHelperee/app/models/secrets_management/group_secrets_managers/user_helper.rbuser_auth_cel_program

When you add a helper method, put it in the shared module if both scopes need the same behavior. Put it in the project or group module if the scope needs its own logic, for example a different CEL program or a different policy naming scheme.

OpenBao namespace hierarchy

Every project or group with Secrets Manager enabled gets its own three-level OpenBao namespace.

LevelSegmentBuilt from
1org_<organization_id>The parent’s organization_id
2group_<root_namespace_id>The parent’s top-level group ID
3project_<project_id> or group_<group_id>The project or group’s own ID

The GitLab subgroup hierarchy is flattened at level 3. A project in a deeply nested subgroup still gets org_<X>/group_<root>/project_<P>, not one segment per subgroup. A top-level group’s own secrets manager reuses its own ID at both level 2 and level 3, for example org_1/group_5/group_5. Secrets Manager does not support user namespaces.

Namespace path construction

Paths are computed live at call time, not read from a stored column. Both ProjectSecretsManager and GroupSecretsManager include a shared PathBuilder module (ee/app/models/secrets_management/path_builder.rb) with the level 1 and level 2 builders:

ruby
def build_org_path(organization_id)
  "org_#{organization_id}"
end

def build_root_namespace_path(root_namespace_id)
  "group_#{root_namespace_id}"
end

Each model computes its own level 3 segment and joins all three:

ruby
# 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

The GroupSecretsManager equivalent builds full_group_namespace_path from group.organization_id, group.root_ancestor.id, and group.id. Because the path depends on the live parent record, code that runs after the parent project or group is destroyed cannot call these methods. That code must use IDs stored elsewhere, described in Checklist for changes.

Scope isolation and cross-level access

Each project, subgroup, and top-level group is its own OpenBao namespace. There is no namespace inheritance. A login is scoped to one namespace and reads only that namespace’s secrets. A project’s namespace does not automatically expose its parent group’s secrets.

CI access to a parent group’s secrets

CI reads a parent group’s secrets through multiple logins, not through inheritance. A project pipeline authenticates directly to the group’s own namespace auth mount, in addition to its own project namespace. The group’s pipeline CEL program accepts the project’s pipeline JSON Web Token (JWT) and checks that the project is a descendant of the group, using the project_group_ids claim.

The claim is set when the JWT is built:

ruby
# ee/lib/secrets_management/pipeline_jwt.rb
def project_group_ids
  source_project.group&.self_and_ancestors&.pluck(:id)&.map(&:to_s) || []
end

The group’s pipeline CEL program then checks the claim contains the group’s own ID:

ruby
# 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'] : []) }

A project does not need its own secrets manager to pull a parent group’s secrets this way. The group must be the project’s group or an ancestor of it. Its secrets manager must be active, Secrets Manager must be available for the group, and the top-level group’s entitlement must allow direct reads.

The non-CI API path has no cross-level access

The direct access-token path validates the token’s own project_id or group_id against the namespace it logs into. A project-scoped token reads only that project’s secrets. A group-scoped token reads only that group’s secrets. To read group-level secrets outside CI, the client uses a group-scoped token. There is no equivalent of project_group_ids on this path.

What GitLab sends the runner

EE::Ci::BuildRunnerPresenter sends the runner an auth path and a secrets mount path in the job payload. It does not send a namespace format for the runner to build itself.

ruby
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
ruby
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

The group equivalent, group_secrets_manager_payload, builds the same shape from GroupSecretsManager#ci_auth_path and ci_secrets_mount_full_path. Because the runner has no hardcoded path format, a change to how GitLab builds namespace paths is transparent to the runner, as long as the OpenBao namespaces match what GitLab computes.

Secrets storage paths in OpenBao

In a namespace, secrets use the key-value version 2 (KV-v2) engine, mounted at a fixed path:

ruby
# ee/app/models/secrets_management/secrets_managers/pipeline_helper.rb
SECRETS_MOUNT_PATH = "secrets/kv"
DATA_ROOT = "explicit"

These constants build the full paths used for reads and writes:

Path kindShape
Mount, namespaced<namespace>/secrets/kv
Secret datasecrets/kv/data/explicit/<secret_name>
Metadatasecrets/kv/metadata/explicit/<secret_name>
Detailed metadatasecrets/kv/detailed-metadata/explicit/<secret_name>

A secret’s KV path is keyed by its name in the namespace. environment and branch are not separate paths. They are stored as custom metadata on the same KV entry.

Secret attributes

ProjectSecret and GroupSecret both inherit shared attributes from BaseSecret: name, description, environment, rotation_info, metadata_version, and the create and update timestamp pairs used to compute secret status, described in States that can combine.

ModelExtra attributes
ProjectSecretproject, branch
GroupSecretgroup, protected

Name validation

The secret model validates each name with a fixed pattern and a length limit.

ruby
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 '_'." }

A secret name can contain only letters, digits, and underscores, up to 255 characters. A service that reads a secret by name must validate the name before calling OpenBao, to prevent injection in the path.

Secrets limits

Secrets limits are application settings, stored in the secrets_manager_settings JSON column on ApplicationSetting.

SettingDefaultMeaning
project_secrets_limit100Maximum secrets per project. Zero means unlimited.
group_secrets_limit500Maximum secrets per group. Zero means unlimited.

Each secrets manager model reads its own limit through Gitlab::CurrentSettings:

ruby
# ProjectSecretsManager
def secrets_limit
  Gitlab::CurrentSettings.project_secrets_limit || DEFAULT_SECRETS_LIMIT
end