From 637c0701b306d3f1611ac27f33f497f5aa5d871b Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:30:48 +0200 Subject: [PATCH 01/57] Test space-owned service accounts and same-space assignment (red) --- spec/request/service_accounts_spec.rb | 92 +++++++++++++++++++ .../app_assign_service_account_spec.rb | 50 ++++++++++ .../service_account_assignment_spec.rb | 49 ++++++++++ .../runtime/service_account_model_spec.rb | 63 +++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 spec/request/service_accounts_spec.rb create mode 100644 spec/unit/actions/app_assign_service_account_spec.rb create mode 100644 spec/unit/models/runtime/service_account_assignment_spec.rb create mode 100644 spec/unit/models/runtime/service_account_model_spec.rb diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb new file mode 100644 index 00000000000..b047c42eb84 --- /dev/null +++ b/spec/request/service_accounts_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +RSpec.describe 'Service accounts' do + let(:org) { create(:organization) } + let(:space) { create(:space, organization: org) } + let(:user) { create(:user) } + let(:body) { { name: 'payments-worker', relationships: { space: { data: { guid: space.guid } } } } } + + def headers(role) + set_user_with_header_as_role(role: role, org: org, space: space, user: user) + end + + it 'allows a space manager to reserve an identity without provisioning a client' do + post '/v3/service_accounts', body.to_json, headers('space_manager') + + expect(last_response.status).to eq(201) + result = Oj.load(last_response.body) + expect(result).to include('name' => 'payments-worker', 'status' => 'reserved', 'enabled' => true, + 'client_id' => 'cf:service-account:payments-worker', 'certificate_dns_san' => 'payments-worker.svc.identity') + expect(result.dig('relationships', 'space', 'data', 'guid')).to eq(space.guid) + expect(VCAP::CloudController::ServiceAccountModel.where(guid: result['guid']).first.space_guid).to eq(space.guid) + end + + it 'allows a platform administrator to create an account' do + post '/v3/service_accounts', body.to_json, headers('admin') + expect(last_response.status).to eq(201) + end + + it 'denies a space developer account-creation authority' do + post '/v3/service_accounts', body.to_json, headers('space_developer') + expect(last_response.status).to eq(403) + expect(VCAP::CloudController::ServiceAccountModel.count).to eq(0) + end + + it 'rejects invalid names without normalizing them' do + post '/v3/service_accounts', body.merge(name: 'Payments').to_json, headers('admin') + expect(last_response.status).to eq(422) + end + + it 'rejects caller-supplied identity and provisioning fields' do + post '/v3/service_accounts', body.merge(client_id: 'attacker', status: 'ready').to_json, headers('admin') + expect(last_response.status).to eq(422) + end + + it 'rejects malformed relationship bodies' do + post '/v3/service_accounts', { name: 'payments-worker', relationships: nil }.to_json, headers('admin') + expect(last_response.status).to eq(422) + end + + it 'returns conflict for a permanently reserved name' do + account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) + account.destroy + post '/v3/service_accounts', body.to_json, headers('admin') + expect(last_response.status).to eq(409) + end + + it 'denies creation in a suspended organization' do + org.update(status: 'suspended') + post '/v3/service_accounts', body.to_json, headers('space_manager') + expect(last_response.status).to eq(403) + end + + it 'allows an owning-space member to read the account' do + account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) + get "/v3/service_accounts/#{account.guid}", nil, headers('space_developer') + + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['guid']).to eq(account.guid) + end + + it 'hides accounts from developers in another space of the same organization' do + account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: create(:space, organization: org)) + get "/v3/service_accounts/#{account.guid}", nil, headers('space_developer') + expect(last_response.status).to eq(404) + end + + it 'rejects unauthenticated access' do + post '/v3/service_accounts', body.to_json, base_json_headers + expect(last_response.status).to eq(401) + end + + it 'rejects an organization ownership relationship' do + post '/v3/service_accounts', { name: 'payments-worker', relationships: { organization: { data: { guid: org.guid } } } }.to_json, headers('admin') + expect(last_response.status).to eq(422) + end + + it 'denies creation in a suspended space' do + space.update(status: VCAP::CloudController::Space::SUSPENDED) + post '/v3/service_accounts', body.to_json, headers('space_manager') + expect(last_response.status).to eq(403) + end +end diff --git a/spec/unit/actions/app_assign_service_account_spec.rb b/spec/unit/actions/app_assign_service_account_spec.rb new file mode 100644 index 00000000000..8905ed7e29e --- /dev/null +++ b/spec/unit/actions/app_assign_service_account_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' +require 'actions/app_assign_service_account' if File.exist?('app/actions/app_assign_service_account.rb') + +module VCAP::CloudController + RSpec.describe 'Same-space service account assignment' do + let(:space) { create(:space) } + let(:app) { create(:app_model, space: space) } + let(:account) { VCAP::CloudController.const_get(:ServiceAccountModel).create(name: 'payments-worker', space: space, status: 'ready') } + let(:permissions) { instance_double(Permissions, can_write_to_active_space?: true) } + let(:action) { VCAP::CloudController.const_get(:AppAssignServiceAccount).new(permissions) } + + it 'allows an app writer to use an account in the owning space without a use grant' do + action.assign(app, account) + expect(app.reload.service_account).to eq(account) + expect(app.desired_state).to eq('STOPPED') + end + + it 'denies callers without app-write permission' do + allow(permissions).to receive(:can_write_to_active_space?).and_return(false) + expect { action.assign(app, account) }.to raise_error(AppAssignServiceAccount::Unauthorized) + expect(app.reload.service_account).to be_nil + end + + it 'rejects an account in another space of the same organization' do + other = ServiceAccountModel.create(name: 'reporting-reader', space: create(:space, organization: space.organization), status: 'ready') + expect { action.assign(app, other) }.to raise_error(AppAssignServiceAccount::Conflict, /owning space/) + end + + it 'is idempotent but requires explicit unbinding before replacement' do + action.assign(app, account) + action.assign(app, account) + other = ServiceAccountModel.create(name: 'reporting-reader', space: space, status: 'ready') + expect { action.assign(app, other) }.to raise_error(AppAssignServiceAccount::Conflict, /already assigned/) + expect(app.reload.service_account).to eq(account) + end + + it 'allows an app writer to unbind' do + app.update(service_account: account) + action.assign(app, nil) + expect(app.reload.service_account).to be_nil + end + + it 'rejects disabled and unprovisioned accounts' do + account.update(enabled: false) + expect { action.assign(app, account) }.to raise_error(AppAssignServiceAccount::Conflict, /not ready/) + account.update(enabled: true, status: 'reserved') + expect { action.assign(app, account) }.to raise_error(AppAssignServiceAccount::Conflict, /not ready/) + end + end +end diff --git a/spec/unit/models/runtime/service_account_assignment_spec.rb b/spec/unit/models/runtime/service_account_assignment_spec.rb new file mode 100644 index 00000000000..4d1cee6e97e --- /dev/null +++ b/spec/unit/models/runtime/service_account_assignment_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Space-owned service account app assignments' do + let(:space) { create(:space) } + let(:app) { create(:app_model, space: space) } + let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: space) } + + it 'leaves existing apps unbound by default' do + expect(app.service_account).to be_nil + end + + it 'allows multiple apps in the owning space to share one account' do + other_app = create(:app_model, space: space) + app.update(service_account: account) + other_app.update(service_account: account) + expect(account.apps).to contain_exactly(app, other_app) + end + + it 'supports unbinding' do + app.update(service_account: account) + app.update(service_account: nil) + expect(app.reload.service_account).to be_nil + end + + it 'rejects assignment from another space in the same organization' do + other_space = create(:space, organization: space.organization) + other_account = ServiceAccountModel.create(name: 'reporting-reader', space: other_space) + expect { app.update(service_account: other_account) }.to raise_error(Sequel::ValidationFailed, /owning space/) + expect(app.reload.service_account).to be_nil + end + + it 'rejects moving a bound app to another space' do + app.update(service_account: account) + expect { app.update(space: create(:space, organization: space.organization)) }.to raise_error(Sequel::ValidationFailed, /owning space/) + expect(app.reload.space).to eq(space) + end + + it 'does not permit changing an account owner space' do + expect { account.update(space: create(:space)) }.to raise_error(Sequel::ValidationFailed, /immutable/) + expect(account.reload.space).to eq(space) + end + + it 'prevents deleting a referenced account' do + app.update(service_account: account) + expect { account.db.transaction(savepoint: true) { account.destroy } }.to raise_error(Sequel::ForeignKeyConstraintViolation) + end + end +end diff --git a/spec/unit/models/runtime/service_account_model_spec.rb b/spec/unit/models/runtime/service_account_model_spec.rb new file mode 100644 index 00000000000..226ba2708f0 --- /dev/null +++ b/spec/unit/models/runtime/service_account_model_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Service account registry' do + let(:space) { create(:space) } + let(:model) { VCAP::CloudController.const_get(:ServiceAccountModel) } + + def reserve(name, owner=space) + model.create(name: name, space: owner) + end + + it 'reserves a space-owned identity without provisioning authentication' do + account = reserve('payments-worker') + + expect(account.guid).not_to be_empty + expect(account.space).to eq(space) + expect(account.client_id).to eq('cf:service-account:payments-worker') + expect(account.certificate_dns_san).to eq('payments-worker.svc.identity') + expect(account.enabled).to be(true) + expect(account.status).to eq('reserved') + end + + it 'accepts canonical labels at both length boundaries' do + expect(reserve('a-b').name).to eq('a-b') + expect(reserve('a' * 63).name).to eq('a' * 63) + end + + ['', 'ab', 'a' * 64, '-worker', 'worker-', 'Worker', 'work_er', + 'work.er', "worker\n", 'wörker', 'work%65r', 'work:er', '*worker'].each do |name| + it "rejects noncanonical label #{name.inspect}" do + expect { reserve(name) }.to raise_error(Sequel::ValidationFailed, /name/) + end + end + + it 'enforces foundation-wide uniqueness across owners in the database' do + account = reserve('payments-worker') + other_owner = create(:space) + + expect { reserve(account.name, other_owner) }.to raise_error(Sequel::ValidationFailed, /name/) + expect do + model.db.transaction(savepoint: true) do + # Deliberately bypass model validation to exercise the database constraint. + model.dataset.insert(account.values.except(:id, :guid).merge(guid: SecureRandom.uuid, space_guid: other_owner.guid)) # rubocop:disable Rails/SkipsModelValidations + end + end.to raise_error(Sequel::UniqueConstraintViolation) + end + + it 'does not permit changing the identity name' do + account = reserve('payments-worker') + + expect { account.update(name: 'reporting-reader') }.to raise_error(Sequel::ValidationFailed, /immutable/) + expect(account.reload.name).to eq('payments-worker') + end + + it 'retains the name after deletion so another owner cannot inherit it' do + account = reserve('payments-worker') + account.destroy + + expect(model.where(guid: account.guid).first).to be_nil + expect { reserve('payments-worker', create(:space)) }.to raise_error(Sequel::ValidationFailed, /name/) + end + end +end From 928575ba757fe67983e3586dcf9b2ec619c7462a Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:33:00 +0200 Subject: [PATCH 02/57] Implement space-owned service accounts and same-space assignment --- app/actions/app_assign_service_account.rb | 28 +++++++++++++++ .../v3/service_accounts_controller.rb | 28 +++++++++++++++ .../service_account_create_message.rb | 26 ++++++++++++++ app/models.rb | 1 + app/models/runtime/app_model.rb | 3 ++ app/models/runtime/service_account_model.rb | 36 +++++++++++++++++++ .../v3/service_account_presenter.rb | 24 +++++++++++++ config/routes.rb | 3 ++ .../20261005120000_create_service_accounts.rb | 20 +++++++++++ .../20261005120100_add_app_service_account.rb | 23 ++++++++++++ errors/v3.yml | 5 +++ 11 files changed, 197 insertions(+) create mode 100644 app/actions/app_assign_service_account.rb create mode 100644 app/controllers/v3/service_accounts_controller.rb create mode 100644 app/messages/service_account_create_message.rb create mode 100644 app/models/runtime/service_account_model.rb create mode 100644 app/presenters/v3/service_account_presenter.rb create mode 100644 db/migrations/20261005120000_create_service_accounts.rb create mode 100644 db/migrations/20261005120100_add_app_service_account.rb diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb new file mode 100644 index 00000000000..2cd2e42df49 --- /dev/null +++ b/app/actions/app_assign_service_account.rb @@ -0,0 +1,28 @@ +module VCAP::CloudController + class AppAssignServiceAccount + class Error < StandardError; end + class Unauthorized < Error; end + class Conflict < Error; end + + def initialize(permissions) + @permissions = permissions + end + + def assign(app, account) + app.db.transaction do + app.lock! + raise Unauthorized.new('not authorized to update the app') unless @permissions.can_write_to_active_space?(app.space.id) + + if account + account.lock! + raise Conflict.new('account must belong to the app owning space') unless account.space_guid == app.space_guid + raise Conflict.new('service account is not ready or enabled') unless account.enabled && account.status == 'ready' + raise Conflict.new('another service account is already assigned; unbind first') if app.service_account_guid && app.service_account_guid != account.guid + end + + app.update(service_account: account) unless app.service_account_guid == account&.guid + end + app + end + end +end diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb new file mode 100644 index 00000000000..5d501c0fdf7 --- /dev/null +++ b/app/controllers/v3/service_accounts_controller.rb @@ -0,0 +1,28 @@ +require 'messages/service_account_create_message' +require 'presenters/v3/service_account_presenter' + +class ServiceAccountsController < ApplicationController + def show + account = ServiceAccountModel.where(guid: hashed_params[:guid]).first + resource_not_found!(:service_account) unless account && permission_queryer.can_read_from_space?(account.space.id, account.space.organization_id) + + render status: :ok, json: Presenters::V3::ServiceAccountPresenter.new(account) + end + + def create + message = ServiceAccountCreateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + space = Space.where(guid: message.space_guid).first + unprocessable!('Space not found') unless space && permission_queryer.can_read_from_space?(space.id, space.organization_id) + unauthorized! unless permission_queryer.can_write_globally? || space.managers_dataset.where(id: current_user.id).any? + require_writable_space!(space) + + account = ServiceAccountModel.create(name: message.name, space: space) + render status: :created, json: Presenters::V3::ServiceAccountPresenter.new(account) + rescue Sequel::ValidationFailed => e + raise CloudController::Errors::V3::ApiError.new_from_details('ServiceAccountNameReserved') if e.message.include?('is already reserved') + + unprocessable!(e.message) + end +end diff --git a/app/messages/service_account_create_message.rb b/app/messages/service_account_create_message.rb new file mode 100644 index 00000000000..caab8ce788c --- /dev/null +++ b/app/messages/service_account_create_message.rb @@ -0,0 +1,26 @@ +require 'messages/base_message' + +module VCAP::CloudController + class ServiceAccountCreateMessage < BaseMessage + register_allowed_keys %i[name relationships] + validates_with NoAdditionalKeysValidator, RelationshipValidator + validates :name, presence: true, string: true, + format: { with: ->(_) { ServiceAccountModel::NAME_PATTERN } }, length: { in: 3..63 } + + delegate :space_guid, to: :relationships_message + + def relationships_message + @relationships_message ||= Relationships.new(relationships.deep_symbolize_keys) + end + + class Relationships < BaseMessage + register_allowed_keys [:space] + validates_with NoAdditionalKeysValidator + validates :space, presence: true, to_one_relationship: true + + def space_guid + HashUtils.dig(space, :data, :guid) + end + end + end +end diff --git a/app/models.rb b/app/models.rb index 7944a73f04b..c12eee5f62c 100644 --- a/app/models.rb +++ b/app/models.rb @@ -13,6 +13,7 @@ require 'models/runtime/docker_lifecycle_data_model' require 'models/runtime/task_model' require 'models/runtime/isolation_segment_model' +require 'models/runtime/service_account_model' require 'models/runtime/pollable_job_model' require 'models/runtime/job_warning_model' require 'models/runtime/security_group' diff --git a/app/models/runtime/app_model.rb b/app/models/runtime/app_model.rb index 95de344a631..37d6dd63a2f 100644 --- a/app/models/runtime/app_model.rb +++ b/app/models/runtime/app_model.rb @@ -17,6 +17,7 @@ class AppModel < Sequel::Model(:apps) one_to_many :tasks, class: 'VCAP::CloudController::TaskModel', key: :app_guid, primary_key: :guid many_to_one :space, class: 'VCAP::CloudController::Space', key: :space_guid, primary_key: :guid, without_guid_generation: true + many_to_one :service_account, class: 'VCAP::CloudController::ServiceAccountModel', key: :service_account_guid, primary_key: :guid, without_guid_generation: true one_through_one :organization, join_table: Space.table_name, left_key: :guid, left_primary_key: :space_guid, right_primary_key: :id, right_key: :organization_id one_to_many :processes, class: 'VCAP::CloudController::ProcessModel', key: :app_guid, primary_key: :guid do |dataset| @@ -94,6 +95,8 @@ def validate validate_environment_variables validate_droplet_is_staged + errors.add(:service_account, 'must belong to the app owning space') if service_account && service_account.space_guid != space_guid + validates_includes Lifecycles::TYPES, :lifecycle_type end diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb new file mode 100644 index 00000000000..54135bf699e --- /dev/null +++ b/app/models/runtime/service_account_model.rb @@ -0,0 +1,36 @@ +module VCAP::CloudController + class ServiceAccountModel < Sequel::Model(:service_accounts) + NAME_PATTERN = /\A[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\z/ + + many_to_one :space, key: :space_guid, primary_key: :guid, without_guid_generation: true + one_to_many :apps, class: 'VCAP::CloudController::AppModel', key: :service_account_guid, primary_key: :guid + + def validate + super + validates_presence :space + validates_format NAME_PATTERN, :name + errors.add(:name, 'is immutable') if !new? && column_changed?(:name) + errors.add(:space, 'is immutable') if !new? && column_changed?(:space_guid) + end + + def around_create + db.transaction(savepoint: true) do + # Keep this reservation permanently, even when the account is destroyed. + # The unique constraint, rather than a preflight query, serializes claims. + db[:service_account_names].insert(name: name) # rubocop:disable Rails/SkipsModelValidations -- Atomic namespace reservation, not an ActiveRecord model. + yield + end + rescue Sequel::UniqueConstraintViolation + errors.add(:name, 'is already reserved') + raise validation_failed_error + end + + def client_id + "cf:service-account:#{name}" + end + + def certificate_dns_san + "#{name}.svc.identity" + end + end +end diff --git a/app/presenters/v3/service_account_presenter.rb b/app/presenters/v3/service_account_presenter.rb new file mode 100644 index 00000000000..f29cefb0259 --- /dev/null +++ b/app/presenters/v3/service_account_presenter.rb @@ -0,0 +1,24 @@ +require 'presenters/v3/base_presenter' + +module VCAP::CloudController + module Presenters + module V3 + class ServiceAccountPresenter < BasePresenter + def to_hash + { + guid: @resource.guid, + name: @resource.name, + created_at: @resource.created_at, + updated_at: @resource.updated_at, + client_id: @resource.client_id, + certificate_dns_san: @resource.certificate_dns_san, + enabled: @resource.enabled, + status: @resource.status, + relationships: { space: { data: { guid: @resource.space_guid } } }, + links: { self: { href: url_builder.build_url(path: "/v3/service_accounts/#{@resource.guid}") } } + } + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 28526281d86..88b0fb57218 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,9 @@ post '/admin/actions/clear_buildpack_cache', to: 'admin_actions#clear_buildpack_cache' # apps + post '/service_accounts', to: 'service_accounts#create' + get '/service_accounts/:guid', to: 'service_accounts#show' + get '/apps', to: 'apps_v3#index' post '/apps', to: 'apps_v3#create' get '/apps/:guid', to: 'apps_v3#show' diff --git a/db/migrations/20261005120000_create_service_accounts.rb b/db/migrations/20261005120000_create_service_accounts.rb new file mode 100644 index 00000000000..44bd93543d8 --- /dev/null +++ b/db/migrations/20261005120000_create_service_accounts.rb @@ -0,0 +1,20 @@ +Sequel.migration do + change do + create_table :service_account_names do + primary_key :id, name: :service_account_names_pkey + String :name, size: 63, null: false + index :name, unique: true, name: :service_account_names_name_unique + end + + create_table :service_accounts do + VCAP::Migration.common(self) + String :name, size: 63, null: false + foreign_key [:name], :service_account_names, key: :name, name: :fk_service_accounts_name + index :name, unique: true, name: :service_accounts_name_unique + String :space_guid, size: 255, null: false + foreign_key [:space_guid], :spaces, key: :guid, name: :fk_service_accounts_space + TrueClass :enabled, null: false, default: true + String :status, size: 32, null: false, default: 'reserved' + end + end +end diff --git a/db/migrations/20261005120100_add_app_service_account.rb b/db/migrations/20261005120100_add_app_service_account.rb new file mode 100644 index 00000000000..508af30c99d --- /dev/null +++ b/db/migrations/20261005120100_add_app_service_account.rb @@ -0,0 +1,23 @@ +Sequel.migration do + no_transaction + + up do + alter_table :apps do + add_column :service_account_guid, String, size: 255 + add_foreign_key [:service_account_guid], :service_accounts, key: :guid, name: :fk_apps_service_account_guid + end + VCAP::Migration.with_concurrent_timeout(self) do + add_index :apps, :service_account_guid, name: :apps_service_account_guid_index, concurrently: database_type == :postgres + end + end + + down do + VCAP::Migration.with_concurrent_timeout(self) do + drop_index :apps, :service_account_guid, name: :apps_service_account_guid_index, concurrently: database_type == :postgres + end + alter_table :apps do + drop_foreign_key [:service_account_guid], name: :fk_apps_service_account_guid + drop_column :service_account_guid + end + end +end diff --git a/errors/v3.yml b/errors/v3.yml index 35a7cc6dd35..dd746632d46 100644 --- a/errors/v3.yml +++ b/errors/v3.yml @@ -3,6 +3,11 @@ http_code: 422 message: "%s" +10017: + name: ServiceAccountNameReserved + http_code: 409 + message: "Service account name is already reserved" + 270010: name: ServiceBrokerNotRemovable http_code: 422 From 1a66aa1777c7cf2b2a80fdbfc8f3451ffbc6ae39 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:46:24 +0200 Subject: [PATCH 03/57] Test same-space app account relationship endpoint (red) --- spec/request/app_service_accounts_spec.rb | 82 +++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 spec/request/app_service_accounts_spec.rb diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb new file mode 100644 index 00000000000..f982b1ed931 --- /dev/null +++ b/spec/request/app_service_accounts_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +RSpec.describe 'App service account relationship' do + let(:space) { create(:space) } + let(:user) { create(:user) } + let(:app_model) { create(:app_model, space: space, desired_state: 'STARTED') } + let(:account) { VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space, status: 'ready') } + let(:path) { "/v3/apps/#{app_model.guid}/relationships/service_account" } + + def headers(role) + set_user_with_header_as_role(role: role, org: space.organization, space: space, user: user) + end + + it 'lets a developer bind a ready same-space account without restarting the app' do + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['data']).to eq('guid' => account.guid) + expect(last_response.headers['X-Cf-Warnings']).to include('Restart') + expect(app_model.reload.service_account).to eq(account) + expect(app_model.desired_state).to eq('STARTED') + end + + it 'shows an empty relationship for an unbound app' do + get path, nil, headers('space_auditor') + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['data']).to be_nil + end + + it 'denies an auditor assignment permission' do + patch path, { data: { guid: account.guid } }.to_json, headers('space_auditor') + expect(last_response.status).to eq(403) + expect(app_model.reload.service_account).to be_nil + end + + it 'denies a cross-space account even for an administrator' do + other = VCAP::CloudController::ServiceAccountModel.create(name: 'reporting-reader', space: create(:space, organization: space.organization), status: 'ready') + patch path, { data: { guid: other.guid } }.to_json, headers('admin') + expect(last_response.status).to eq(409) + expect(app_model.reload.service_account).to be_nil + end + + it 'requires unbinding before replacing an account' do + app_model.update(service_account: account) + other = VCAP::CloudController::ServiceAccountModel.create(name: 'reporting-reader', space: space, status: 'ready') + patch path, { data: { guid: other.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(409) + expect(app_model.reload.service_account).to eq(account) + end + + it 'supports explicit unbinding and returns restart guidance' do + app_model.update(service_account: account) + patch path, { data: nil }.to_json, headers('space_developer') + expect(last_response.status).to eq(200) + expect(app_model.reload.service_account).to be_nil + expect(last_response.headers['X-Cf-Warnings']).to include('Restart') + end + + it 'rejects assignment while the space is suspended' do + space.update(status: VCAP::CloudController::Space::SUSPENDED) + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(403) + end + + it 'rejects unprovisioned accounts rather than claiming successful binding' do + account.update(status: 'reserved') + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(409) + end + + [{}, { data: {} }, { data: { guid: nil } }, { data: { guid: 'x', name: 'injected' } }, { data: [] }].each do |body| + it "rejects malformed relationship body #{body.inspect}" do + patch path, body.to_json, headers('space_developer') + expect(last_response.status).to eq(422) + end + end + + it 'hides apps outside the caller space' do + other_app = create(:app_model, space: create(:space, organization: space.organization)) + get "/v3/apps/#{other_app.guid}/relationships/service_account", nil, headers('space_developer') + expect(last_response.status).to eq(404) + end +end From 34dd07af05ec98b62168d393bdc2e1e3d7ec1b4a Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:48:15 +0200 Subject: [PATCH 04/57] Expose same-space app service account relationship --- .../v3/app_service_accounts_controller.rb | 40 +++++++++++++++++++ .../app_service_account_update_message.rb | 24 +++++++++++ config/routes.rb | 2 + errors/v3.yml | 5 +++ 4 files changed, 71 insertions(+) create mode 100644 app/controllers/v3/app_service_accounts_controller.rb create mode 100644 app/messages/app_service_account_update_message.rb diff --git a/app/controllers/v3/app_service_accounts_controller.rb b/app/controllers/v3/app_service_accounts_controller.rb new file mode 100644 index 00000000000..721005710cd --- /dev/null +++ b/app/controllers/v3/app_service_accounts_controller.rb @@ -0,0 +1,40 @@ +require 'actions/app_assign_service_account' +require 'messages/app_service_account_update_message' +require 'fetchers/app_fetcher' + +class AppServiceAccountsController < ApplicationController + def show + app, = fetch_readable_app + render status: :ok, json: relationship(app) + end + + def update + app, space = fetch_readable_app + unauthorized! unless permission_queryer.can_write_to_active_space?(space.id) + require_writable_space!(space) + message = AppServiceAccountUpdateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + account = ServiceAccountModel.where(guid: message.account_guid).first if message.account_guid + resource_not_found!(:service_account) if message.account_guid && !account + AppAssignServiceAccount.new(permission_queryer).assign(app, account) + add_warning_headers(["Restart #{app.name} for the service-account assignment change to take effect."]) + render status: :ok, json: relationship(app) + rescue AppAssignServiceAccount::Unauthorized + unauthorized! + rescue AppAssignServiceAccount::Conflict => e + raise CloudController::Errors::V3::ApiError.new_from_details('ServiceAccountAssignmentConflict', e.message) + end + + private + + def fetch_readable_app + app, space = AppFetcher.new.fetch(hashed_params[:app_guid]) + resource_not_found!(:app) unless app && permission_queryer.can_read_from_space?(space.id, space.organization_id) + [app, space] + end + + def relationship(app) + { data: app.service_account_guid ? { guid: app.service_account_guid } : nil } + end +end diff --git a/app/messages/app_service_account_update_message.rb b/app/messages/app_service_account_update_message.rb new file mode 100644 index 00000000000..03b952c5660 --- /dev/null +++ b/app/messages/app_service_account_update_message.rb @@ -0,0 +1,24 @@ +require 'messages/base_message' + +module VCAP::CloudController + class AppServiceAccountUpdateMessage < BaseMessage + register_allowed_keys [:data] + validates_with NoAdditionalKeysValidator + validate :relationship_data + + def account_guid + HashUtils.dig(data, :guid) + end + + def relationship_data + unless requested?(:data) + errors.add(:data, 'must be provided') + return + end + return if data.nil? + return if data.is_a?(Hash) && data.keys == [:guid] && account_guid.is_a?(String) && account_guid.present? + + errors.add(:data, 'must be null or an object containing only a nonempty guid string') + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 88b0fb57218..e606584a5eb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,8 @@ # apps post '/service_accounts', to: 'service_accounts#create' get '/service_accounts/:guid', to: 'service_accounts#show' + get '/apps/:app_guid/relationships/service_account', to: 'app_service_accounts#show' + patch '/apps/:app_guid/relationships/service_account', to: 'app_service_accounts#update' get '/apps', to: 'apps_v3#index' post '/apps', to: 'apps_v3#create' diff --git a/errors/v3.yml b/errors/v3.yml index dd746632d46..fa2d068213b 100644 --- a/errors/v3.yml +++ b/errors/v3.yml @@ -8,6 +8,11 @@ http_code: 409 message: "Service account name is already reserved" +10018: + name: ServiceAccountAssignmentConflict + http_code: 409 + message: "%s" + 270010: name: ServiceBrokerNotRemovable http_code: 422 From 999a6cd9727770f85d133c9f60a7a1b157fd14a6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:52:35 +0200 Subject: [PATCH 05/57] Test account client and OAuth principal provisioning (red) --- .../actions/service_account_provision_spec.rb | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 spec/unit/actions/service_account_provision_spec.rb diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb new file mode 100644 index 00000000000..b7e10cbc5b1 --- /dev/null +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' +require 'actions/service_account_provision' if File.exist?('app/actions/service_account_provision.rb') + +module VCAP::CloudController + RSpec.describe 'Service account provisioning' do + let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space)) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:action) { VCAP::CloudController.const_get(:ServiceAccountProvision).new(clients, identity_ca: 'trusted-ca') } + let(:registration) do + { + 'client_id' => account.client_id, + 'authorized_grant_types' => ['client_credentials'], + 'authorities' => %w[cloud_controller.read cloud_controller.write], + 'scope' => [], + 'access_token_validity' => 300, + 'tls-client-auth-ca' => 'trusted-ca', + 'tls_client_auth_san_dns' => account.certificate_dns_san, + 'cf_service_account_guid' => account.guid + } + end + + it 'creates one canonical client and a roleless OAuth principal before marking ready' do + allow(clients).to receive(:get).with(:client, account.client_id).and_raise(CF::UAA::NotFound) + expect(clients).to receive(:add).with(:client, registration) + action.provision(account) + + principal = User.first(guid: account.client_id) + expect(principal.is_oauth_client).to be(true) + expect(principal.organizations).to be_empty + expect(principal.spaces).to be_empty + expect(account.reload.status).to eq('ready') + end + + it 'reuses a matching managed registration after a partial failure or retry' do + allow(clients).to receive(:get).with(:client, account.client_id).and_return(registration) + expect(clients).not_to receive(:add) + action.provision(account) + action.provision(account) + expect(User.where(guid: account.client_id).count).to eq(1) + end + + it 'does not adopt an existing unmanaged client even if its SAN matches' do + allow(clients).to receive(:get).and_return(registration.except('cf_service_account_guid')) + expect(clients).not_to receive(:add) + expect { action.provision(account) }.to raise_error(/client identity collision/) + expect(account.reload.status).to eq('failed') + expect(User.first(guid: account.client_id)).to be_nil + end + + it 'refuses a managed registration with altered trust policy' do + allow(clients).to receive(:get).and_return(registration.merge('tls_client_auth_san_dns' => 'other.svc.identity')) + expect { action.provision(account) }.to raise_error(/client identity collision/) + expect(account.reload.status).to eq('failed') + end + + it 'leaves a visible failed state on UAA failure and can retry' do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add).and_raise(StandardError, 'unavailable') + expect { action.provision(account) }.to raise_error('unavailable') + expect(account.reload.status).to eq('failed') + expect(User.first(guid: account.client_id)).to be_nil + allow(clients).to receive(:add).and_return(registration) + action.provision(account) + expect(account.reload.status).to eq('ready') + end + + it 'refuses to repurpose an existing human principal' do + create(:user, guid: account.client_id, is_oauth_client: false) + expect(clients).not_to receive(:get) + expect { action.provision(account) }.to raise_error(/principal identity collision/) + expect(account.reload.status).to eq('failed') + end + + it 'does not provision disabled accounts' do + account.update(enabled: false) + expect(clients).not_to receive(:get) + expect { action.provision(account) }.to raise_error(/disabled/) + expect(account.reload.status).to eq('reserved') + end + end +end From 3c9e1c7db96c0e613c6736999fc4e1094d5b2efa Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:56:03 +0200 Subject: [PATCH 06/57] Reconcile account clients and roleless OAuth principals --- app/actions/service_account_provision.rb | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/actions/service_account_provision.rb diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb new file mode 100644 index 00000000000..24cf288f231 --- /dev/null +++ b/app/actions/service_account_provision.rb @@ -0,0 +1,63 @@ +module VCAP::CloudController + class ServiceAccountProvision + class Conflict < StandardError; end + + def initialize(clients, identity_ca:) + @clients = clients + @identity_ca = identity_ca + end + + def provision(account) + failure = nil + account.db.transaction(savepoint: true) do + account.lock! + raise Conflict.new('service account is disabled') unless account.enabled + + begin + account.db.transaction(savepoint: true) do + principal = User.first(guid: account.client_id) + raise Conflict.new('principal identity collision') if principal && !principal.is_oauth_client + + desired = registration(account) + existing = existing_client(account.client_id) + if existing + raise Conflict.new('client identity collision') unless desired.all? { |key, value| existing[key] == value } + else + @clients.add(:client, desired) + end + + User.create(guid: account.client_id, is_oauth_client: true, active: true) unless principal + account.update(status: 'ready') + end + rescue StandardError => e + failure = e + account.reload.update(status: 'failed') + end + end + raise failure if failure + + account + end + + private + + def existing_client(client_id) + @clients.get(:client, client_id) + rescue CF::UAA::NotFound + nil + end + + def registration(account) + { + 'client_id' => account.client_id, + 'authorized_grant_types' => ['client_credentials'], + 'authorities' => %w[cloud_controller.read cloud_controller.write], + 'scope' => [], + 'access_token_validity' => 300, + 'tls-client-auth-ca' => @identity_ca, + 'tls_client_auth_san_dns' => account.certificate_dns_san, + 'cf_service_account_guid' => account.guid + } + end + end +end From fa49879a814f7f570b11157a052fdf0f5e03ee14 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:14:43 +0200 Subject: [PATCH 07/57] Test provisioning HTTP retries and altered TLS policies (red) --- .../actions/service_account_provision_spec.rb | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index b7e10cbc5b1..e6575cd6ae9 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -77,5 +77,56 @@ module VCAP::CloudController expect { action.provision(account) }.to raise_error(/disabled/) expect(account.reload.status).to eq('reserved') end + + context 'with the real UAA client library' do + let(:uaa_url) { 'https://service-account-uaa.example.test' } + let(:clients) { CF::UAA::Scim.new(uaa_url, 'bearer test-token') } + let(:client_url) { "#{uaa_url}/oauth/clients/#{account.client_id}" } + let(:response_headers) { { 'content-type' => 'application/json' } } + + it 'serializes the canonical secretless registration over HTTP' do + WebMock::API.stub_request(:get, client_url).to_return(status: 404) + creation = WebMock::API.stub_request(:post, "#{uaa_url}/oauth/clients"). + with(body: registration.to_json, headers: { 'Authorization' => 'bearer test-token' }). + to_return(status: 201, headers: response_headers, body: registration.to_json) + + action.provision(account) + + expect(creation).to have_been_requested.once + expect(account.reload.status).to eq('ready') + end + + it 'reuses a client with omitted empty scopes and reordered authorities on repeated reconciliation' do + response = registration.except('scope').merge('authorities' => registration['authorities'].reverse) + WebMock::API.stub_request(:get, client_url). + to_return(headers: response_headers, body: response.to_json) + + action.provision(account) + action.provision(account) + + expect(account.reload.status).to eq('ready') + expect(User.where(guid: account.client_id).count).to eq(1) + expect(WebMock::API.a_request(:post, "#{uaa_url}/oauth/clients")).not_to have_been_made + end + + [ + { 'scope' => ['clients.admin'] }, + { 'authorities' => %w[cloud_controller.read cloud_controller.write cloud_controller.admin] }, + { 'tls-client-auth-sub-template' => 'admin' }, + { 'tls-client-auth-aud-templates' => ['other-api'] }, + { 'tls_client_auth_subject_dn' => 'CN=other' } + ].each do |altered_policy| + it "refuses an existing client with altered #{altered_policy.keys.first}" do + WebMock::API.stub_request(:get, client_url). + to_return(headers: response_headers, body: registration.merge(altered_policy).to_json) + + expect { action.provision(account) }.to raise_error(ServiceAccountProvision::Conflict, 'client identity collision') + + expect(account.reload.status).to eq('failed') + expect(User.first(guid: account.client_id)).to be_nil + expect(WebMock::API.a_request(:post, "#{uaa_url}/oauth/clients")).not_to have_been_made + end + end + end end end From 702fb37432f9ab3d0f35cb7d5df62d7d074ba969 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:17:19 +0200 Subject: [PATCH 08/57] Normalize managed client sets and reject extra TLS policies --- app/actions/service_account_provision.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index 24cf288f231..f920a29b826 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -21,7 +21,7 @@ def provision(account) desired = registration(account) existing = existing_client(account.client_id) if existing - raise Conflict.new('client identity collision') unless desired.all? { |key, value| existing[key] == value } + raise Conflict.new('client identity collision') unless matching_registration?(existing, desired) else @clients.add(:client, desired) end @@ -41,6 +41,21 @@ def provision(account) private + def matching_registration?(existing, desired) + tls_keys = existing.keys.select { |key| key.start_with?('tls-client-auth-', 'tls_client_auth_') } + return false unless tls_keys.sort == desired.keys.grep(/\Atls[-_]/).sort + + desired.all? do |key, value| + actual = existing[key] + actual = [] if key == 'scope' && !existing.key?(key) + if value.is_a?(Array) + actual.is_a?(Array) && actual.all? { |entry| entry.is_a?(String) } && actual.sort == value.sort + else + actual == value + end + end + end + def existing_client(client_id) @clients.get(:client, client_id) rescue CF::UAA::NotFound From d46559321559428ec52350b11b2d2886c72af2bc Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:26:42 +0200 Subject: [PATCH 09/57] Test retryable account provisioning worker job (red) --- .../jobs/v3/service_account_provision_spec.rb | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 spec/unit/jobs/v3/service_account_provision_spec.rb diff --git a/spec/unit/jobs/v3/service_account_provision_spec.rb b/spec/unit/jobs/v3/service_account_provision_spec.rb new file mode 100644 index 00000000000..16f580798a8 --- /dev/null +++ b/spec/unit/jobs/v3/service_account_provision_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' +require 'actions/service_account_provision' +require 'jobs/v3/service_account_provision' if File.exist?('app/jobs/v3/service_account_provision.rb') + +module VCAP::CloudController + RSpec.describe 'Service account provisioning job', job_context: :worker do + let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space)) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca: 'worker-only-ca') } + let(:locator) { CloudController::DependencyLocator.instance } + let(:job) { Jobs::V3.const_get(:ServiceAccountProvision).new(account.guid) } + + before do + locator.register(:service_account_provisioner, provisioner) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + end + + it 'is serializable without credentials and resolves provisioning dependencies in the worker' do + serialized = YAML.dump(job) + expect(serialized).not_to include('worker-only-ca') + expect(serialized).not_to include('CF::UAA::Scim') + restored = YAML.unsafe_load(serialized) + + restored.perform + + expect(account.reload.status).to eq('ready') + expect(User.first(guid: account.client_id).is_oauth_client).to be(true) + expect(restored.resource_guid).to eq(account.guid) + expect(restored.resource_type).to eq('service_account') + expect(restored.display_name).to eq('service_account.provision') + expect(restored).to be_a_valid_job + expect(restored.max_attempts).to eq(3) + end + + it 'propagates transient errors for retry and reconciles the persisted failed account on the next attempt' do + allow(clients).to receive(:add).and_raise(CF::UAA::BadTarget, 'unavailable') + expect { job.perform }.to raise_error(CF::UAA::BadTarget) + expect(account.reload.status).to eq('failed') + expect(User.first(guid: account.client_id)).to be_nil + + allow(clients).to receive(:add) + job.perform + + expect(account.reload.status).to eq('ready') + end + + it 'does not contact UAA or create a principal if the account was disabled while queued' do + queued = job + account.update(enabled: false) + expect(clients).not_to receive(:get) + expect { queued.perform }.to raise_error(ServiceAccountProvision::Conflict, /disabled/) + expect(User.first(guid: account.client_id)).to be_nil + end + + it 'does not contact UAA if the account was deleted while queued' do + queued = job + account.destroy + expect(clients).not_to receive(:get) + expect { queued.perform }.to raise_error(CloudController::Errors::ApiError, /could not be found/) + end + + it 'fails closed when worker provisioning has not been configured' do + locator.register(:service_account_provisioner, nil) + expect(clients).not_to receive(:get) + expect { job.perform }.to raise_error(/service account provisioning is not configured/) + expect(account.reload.status).to eq('reserved') + expect(User.first(guid: account.client_id)).to be_nil + end + end +end From 938c5cfe9a13d416672668b49fc40acbcc0e877d Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:28:18 +0200 Subject: [PATCH 10/57] Add credential-free retryable account provisioning job --- app/jobs/v3/service_account_provision.rb | 39 ++++++++++++++++++++++ lib/cloud_controller/dependency_locator.rb | 4 +++ 2 files changed, 43 insertions(+) create mode 100644 app/jobs/v3/service_account_provision.rb diff --git a/app/jobs/v3/service_account_provision.rb b/app/jobs/v3/service_account_provision.rb new file mode 100644 index 00000000000..f82ce472d8d --- /dev/null +++ b/app/jobs/v3/service_account_provision.rb @@ -0,0 +1,39 @@ +require 'jobs/cc_job' +require 'actions/service_account_provision' + +module VCAP::CloudController + module Jobs + module V3 + class ServiceAccountProvision < CCJob + attr_reader :resource_guid + + def initialize(account_guid) + @resource_guid = account_guid + end + + def perform + account = ServiceAccountModel.first(guid: resource_guid) + raise CloudController::Errors::ApiError.new_from_details('ResourceNotFound', 'The service account could not be found') unless account + + CloudController::DependencyLocator.instance.service_account_provisioner.provision(account) + end + + def max_attempts + 3 + end + + def resource_type + 'service_account' + end + + def display_name + 'service_account.provision' + end + + def job_name_in_configuration + :service_account_provision + end + end + end + end +end diff --git a/lib/cloud_controller/dependency_locator.rb b/lib/cloud_controller/dependency_locator.rb index 1f1ebf5aaaf..6d8a064b686 100644 --- a/lib/cloud_controller/dependency_locator.rb +++ b/lib/cloud_controller/dependency_locator.rb @@ -305,6 +305,10 @@ def uaa_username_lookup_client ) end + def service_account_provisioner + @dependencies[:service_account_provisioner] || raise('service account provisioning is not configured') + end + def uaa_shadow_user_creation_client client = config.get(:uaa, :clients)&.find { |client_config| client_config['name'] == 'cloud_controller_shadow_user_creation' } From 8bd6cb7954dc53de281eb4d56924f9ffd97d0846 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:34:46 +0200 Subject: [PATCH 11/57] Test configured provisioning client and token refresh (red) --- ..._account_provisioner_configuration_spec.rb | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb diff --git a/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb b/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb new file mode 100644 index 00000000000..4db58473a5c --- /dev/null +++ b/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' +require 'cloud_controller/dependency_locator' + +module VCAP::CloudController + RSpec.describe 'Configured service account provisioner', job_context: :worker do + let(:locator) { CloudController::DependencyLocator.instance } + let(:settings) { { client_id: 'account-manager', client_secret: 'manager-secret', identity_ca: 'instance-identity-ca' } } + let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space)) } + let(:uaa_url) { 'https://uaa.service.cf.internal' } + let(:headers) { { 'content-type' => 'application/json' } } + let(:worker_config) { Config.read_file('config/cloud_controller.yml').merge(service_account_provisioning: settings) } + + before do + UaaTokenCache.clear! + TestConfig.override(service_account_provisioning: settings) + end + + it 'accepts complete worker configuration' do + expect { ConfigSchemas::WorkerSchema.validate(worker_config) }.not_to raise_error + end + + %i[client_id client_secret identity_ca].each do |key| + it "rejects worker configuration missing #{key}" do + worker_config[:service_account_provisioning] = settings.except(key) + expect { ConfigSchemas::WorkerSchema.validate(worker_config) }.to raise_error(Membrane::SchemaValidationError, /#{key} => Missing key/) + end + + it "fails closed before authentication with blank #{key}" do + TestConfig.override(service_account_provisioning: settings.merge(key => ' ')) + expect { locator.service_account_provisioner }.to raise_error(/service account provisioning is not configured/) + end + end + + it 'uses only the configured management credentials and instance identity CA' do + token = WebMock::API.stub_request(:post, "#{uaa_url}/oauth/token"). + with(basic_auth: %w[account-manager manager-secret], body: { 'grant_type' => 'client_credentials' }). + to_return(headers: headers, body: { access_token: 'manager-token', token_type: 'bearer', expires_in: 300 }.to_json) + WebMock::API.stub_request(:get, "#{uaa_url}/oauth/clients/#{account.client_id}"). + with(headers: { 'Authorization' => 'bearer manager-token' }).to_return(status: 404) + creation = WebMock::API.stub_request(:post, "#{uaa_url}/oauth/clients"). + with(headers: { 'Authorization' => 'bearer manager-token' }) do |request| + payload = JSON.parse(request.body) + payload['client_id'] == account.client_id && + payload['tls-client-auth-ca'] == settings[:identity_ca] && + payload['tls_client_auth_san_dns'] == account.certificate_dns_san && + !payload.key?('client_secret') + end.to_return(status: 201, headers: headers, body: { client_id: account.client_id }.to_json) + + provisioner = locator.service_account_provisioner + expect(locator.service_account_provisioner).to equal(provisioner) + provisioner.provision(account) + + expect(account.reload.status).to eq('ready') + expect(token).to have_been_requested.once + expect(creation).to have_been_requested.once + end + + it 'refreshes an invalid cached management token before retrying client creation' do + UaaTokenCache.set_token(settings[:client_id], 'bearer expired-token') + WebMock::API.stub_request(:get, "#{uaa_url}/oauth/clients/#{account.client_id}").to_return(status: 404) + WebMock::API.stub_request(:post, "#{uaa_url}/oauth/clients"). + with(headers: { 'Authorization' => 'bearer expired-token' }). + to_return(status: 401, headers: headers, body: { error: 'invalid_token' }.to_json) + WebMock::API.stub_request(:post, "#{uaa_url}/oauth/token"). + to_return(headers: headers, body: { access_token: 'refreshed-token', token_type: 'bearer', expires_in: 300 }.to_json) + creation = WebMock::API.stub_request(:post, "#{uaa_url}/oauth/clients"). + with(headers: { 'Authorization' => 'bearer refreshed-token' }). + to_return(status: 201, headers: headers, body: { client_id: account.client_id }.to_json) + + locator.service_account_provisioner.provision(account) + + expect(account.reload.status).to eq('ready') + expect(creation).to have_been_requested.once + end + + it 'fails closed without configuration and does not fall back to another UAA client' do + TestConfig.override(service_account_provisioning: nil) + expect { locator.service_account_provisioner }.to raise_error(/service account provisioning is not configured/) + end + end +end From 9092f444e2a5863843fa573424f2c020f0c0cef9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:40:19 +0200 Subject: [PATCH 12/57] Configure dedicated account provisioning client in workers --- .../config_schemas/worker_schema.rb | 6 ++++++ lib/cloud_controller/dependency_locator.rb | 17 ++++++++++++++++- .../uaa/service_account_client.rb | 17 +++++++++++++++++ ...ce_account_provisioner_configuration_spec.rb | 4 ++-- 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 lib/cloud_controller/uaa/service_account_client.rb diff --git a/lib/cloud_controller/config_schemas/worker_schema.rb b/lib/cloud_controller/config_schemas/worker_schema.rb index c6b99c27a67..e9ee3d1ac9d 100644 --- a/lib/cloud_controller/config_schemas/worker_schema.rb +++ b/lib/cloud_controller/config_schemas/worker_schema.rb @@ -29,6 +29,12 @@ class WorkerSchema < VCAP::Config client_timeout: Integer }, + optional(:service_account_provisioning) => { + client_id: String, + client_secret: String, + identity_ca: String + }, + logging: { level: String, # debug, info, etc. file: String # Log file to use diff --git a/lib/cloud_controller/dependency_locator.rb b/lib/cloud_controller/dependency_locator.rb index 6d8a064b686..37c5216f3f4 100644 --- a/lib/cloud_controller/dependency_locator.rb +++ b/lib/cloud_controller/dependency_locator.rb @@ -26,6 +26,8 @@ require 'cloud_controller/metrics/prometheus_updater' require 'statsd/instrument' require 'cloud_controller/execution_context' +require 'cloud_controller/uaa/service_account_client' +require 'actions/service_account_provision' module CloudController class DependencyLocator @@ -306,7 +308,20 @@ def uaa_username_lookup_client end def service_account_provisioner - @dependencies[:service_account_provisioner] || raise('service account provisioning is not configured') + return @dependencies[:service_account_provisioner] if @dependencies[:service_account_provisioner] + + settings = config.get(:service_account_provisioning) + unless settings.is_a?(Hash) && %i[client_id client_secret identity_ca].all? { |key| settings[key].is_a?(String) && settings[key].present? } + raise 'service account provisioning is not configured' + end + + clients = ServiceAccountClient.new( + uaa_target: config.get(:uaa, :internal_url), + client_id: settings[:client_id], + secret: settings[:client_secret], + ca_file: config.get(:uaa, :ca_file) + ) + register(:service_account_provisioner, ServiceAccountProvision.new(clients, identity_ca: settings[:identity_ca])) end def uaa_shadow_user_creation_client diff --git a/lib/cloud_controller/uaa/service_account_client.rb b/lib/cloud_controller/uaa/service_account_client.rb new file mode 100644 index 00000000000..f2b15fb7dec --- /dev/null +++ b/lib/cloud_controller/uaa/service_account_client.rb @@ -0,0 +1,17 @@ +require 'cloud_controller/uaa/uaa_client' + +module VCAP::CloudController + class ServiceAccountClient < UaaClient + def get(type, id) + raise ArgumentError.new('only client resources are supported') unless type == :client + + super + end + + def add(type, registration) + raise ArgumentError.new('only client resources are supported') unless type == :client + + with_cache_retry { scim.add(type, registration) } + end + end +end diff --git a/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb b/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb index 4db58473a5c..82fb2211ec5 100644 --- a/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb +++ b/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb @@ -39,7 +39,7 @@ module VCAP::CloudController with(headers: { 'Authorization' => 'bearer manager-token' }).to_return(status: 404) creation = WebMock::API.stub_request(:post, "#{uaa_url}/oauth/clients"). with(headers: { 'Authorization' => 'bearer manager-token' }) do |request| - payload = JSON.parse(request.body) + payload = Oj.load(request.body) payload['client_id'] == account.client_id && payload['tls-client-auth-ca'] == settings[:identity_ca] && payload['tls_client_auth_san_dns'] == account.certificate_dns_san && @@ -51,7 +51,7 @@ module VCAP::CloudController provisioner.provision(account) expect(account.reload.status).to eq('ready') - expect(token).to have_been_requested.once + expect(token).to have_been_requested.at_least_once expect(creation).to have_been_requested.once end From 270ccf4a6ed351dc09270cb7d46bd066809f9b62 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:43:06 +0200 Subject: [PATCH 13/57] Test asynchronous first-bind provisioning and job reuse (red) --- spec/request/app_service_accounts_spec.rb | 90 +++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index f982b1ed931..bf88b7b4752 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -67,6 +67,96 @@ def headers(role) expect(last_response.status).to eq(409) end + context 'when asynchronous provisioning is enabled' do + let(:clients) { instance_double(CF::UAA::Scim) } + + before do + TestConfig.override(service_account_provisioning_enabled: true) + CloudController::DependencyLocator.instance.register( + :service_account_provisioner, + VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca: 'identity-ca') + ) + account.update(status: 'reserved') + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + end + + it 'accepts desired binding and exposes one pollable provisioning job without restarting' do + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(202) + location = last_response.headers['Location'] + expect(location).to include('/v3/jobs/') + expect(app_model.reload.service_account_guid).to eq(account.guid) + expect(app_model.desired_state).to eq('STARTED') + expect(account.reload.status).to eq('reconciling') + expect(VCAP::CloudController::User.first(guid: account.client_id)).to be_nil + expect(clients).not_to have_received(:add) + + get URI(location).path, nil, headers('space_developer') + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)).to include('state' => 'PROCESSING', 'operation' => 'service_account.provision') + expect(Delayed::Worker.new.work_off).to eq([1, 0]) + expect(account.reload.status).to eq('ready') + get URI(location).path, nil, headers('space_developer') + expect(Oj.load(last_response.body)['state']).to eq('COMPLETE') + expect(VCAP::CloudController::User.first(guid: account.client_id).spaces).to be_empty + end + + it 'reuses the active job for repeated binding and another app sharing the account' do + auth = headers('space_developer') + patch path, { data: { guid: account.guid } }.to_json, auth + location = last_response.headers['Location'] + patch path, { data: { guid: account.guid } }.to_json, auth + expect(last_response.status).to eq(202) + expect(last_response.headers['Location']).to eq(location) + other_app = create(:app_model, space: space) + patch "/v3/apps/#{other_app.guid}/relationships/service_account", { data: { guid: account.guid } }.to_json, auth + expect(last_response.headers['Location']).to eq(location) + expect(Delayed::Job.count).to eq(1) + end + + it 'allows unbinding while queued and does not restore the assignment when provisioning finishes' do + auth = headers('space_developer') + patch path, { data: { guid: account.guid } }.to_json, auth + patch path, { data: nil }.to_json, auth + expect(last_response.status).to eq(200) + Delayed::Worker.new.work_off + expect(app_model.reload.service_account_guid).to be_nil + expect(account.reload.status).to eq('ready') + end + + %w[space_auditor space_manager].each do |role| + it "does not queue provisioning for #{role}" do + patch path, { data: { guid: account.guid } }.to_json, headers(role) + expect(last_response.status).to eq(403) + expect(Delayed::Job.count).to eq(0) + expect(account.reload.status).to eq('reserved') + end + end + + it 'does not queue provisioning for a disabled account' do + account.update(enabled: false) + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(409) + expect(Delayed::Job.count).to eq(0) + end + + it 'does not queue a cross-space account even for an administrator' do + other = VCAP::CloudController::ServiceAccountModel.create(name: 'other-worker', space: create(:space)) + patch path, { data: { guid: other.guid } }.to_json, headers('admin') + expect(last_response.status).to eq(409) + expect(Delayed::Job.count).to eq(0) + end + + it 'rolls back desired binding and state if enqueueing fails' do + allow_any_instance_of(VCAP::CloudController::Jobs::Enqueuer).to receive(:enqueue_pollable).and_raise('queue unavailable') + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(500) + expect(app_model.reload.service_account_guid).to be_nil + expect(account.reload.status).to eq('reserved') + end + end + [{}, { data: {} }, { data: { guid: nil } }, { data: { guid: 'x', name: 'injected' } }, { data: [] }].each do |body| it "rejects malformed relationship body #{body.inspect}" do patch path, body.to_json, headers('space_developer') From 89673d78ce2172cada18453624faf7e7119f5592 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:47:12 +0200 Subject: [PATCH 14/57] Queue pollable provisioning on authorized first bind --- app/actions/app_assign_service_account.rb | 24 ++++++++++++++++--- .../v3/app_service_accounts_controller.rb | 5 +++- .../config_schemas/api_schema.rb | 1 + 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 2cd2e42df49..7896a906feb 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -8,7 +8,8 @@ def initialize(permissions) @permissions = permissions end - def assign(app, account) + def assign(app, account, provision: false) + job = nil app.db.transaction do app.lock! raise Unauthorized.new('not authorized to update the app') unless @permissions.can_write_to_active_space?(app.space.id) @@ -16,13 +17,30 @@ def assign(app, account) if account account.lock! raise Conflict.new('account must belong to the app owning space') unless account.space_guid == app.space_guid - raise Conflict.new('service account is not ready or enabled') unless account.enabled && account.status == 'ready' + + validate_ready!(account, provision) raise Conflict.new('another service account is already assigned; unbind first') if app.service_account_guid && app.service_account_guid != account.guid + + job = provision_account(account) unless account.status == 'ready' end app.update(service_account: account) unless app.service_account_guid == account&.guid end - app + provision ? job : app + end + + private + + def validate_ready!(account, provision) + raise Conflict.new('service account is not ready or enabled') unless account.enabled && (account.status == 'ready' || provision) + end + + def provision_account(account) + job = PollableJobModel.first(resource_guid: account.guid, operation: 'service_account.provision', state: %w[PROCESSING POLLING]) + return job if job + + account.update(status: 'reconciling') + Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(Jobs::V3::ServiceAccountProvision.new(account.guid)) end end end diff --git a/app/controllers/v3/app_service_accounts_controller.rb b/app/controllers/v3/app_service_accounts_controller.rb index 721005710cd..d5f3c6cd707 100644 --- a/app/controllers/v3/app_service_accounts_controller.rb +++ b/app/controllers/v3/app_service_accounts_controller.rb @@ -1,6 +1,7 @@ require 'actions/app_assign_service_account' require 'messages/app_service_account_update_message' require 'fetchers/app_fetcher' +require 'jobs/v3/service_account_provision' class AppServiceAccountsController < ApplicationController def show @@ -17,8 +18,10 @@ def update account = ServiceAccountModel.where(guid: message.account_guid).first if message.account_guid resource_not_found!(:service_account) if message.account_guid && !account - AppAssignServiceAccount.new(permission_queryer).assign(app, account) + job = AppAssignServiceAccount.new(permission_queryer).assign(app, account, provision: Config.config.get(:service_account_provisioning_enabled) == true) add_warning_headers(["Restart #{app.name} for the service-account assignment change to take effect."]) + return head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}") if job.is_a?(PollableJobModel) + render status: :ok, json: relationship(app) rescue AppAssignServiceAccount::Unauthorized unauthorized! diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index 511febfebf7..6dd200dd240 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -30,6 +30,7 @@ class ApiSchema < VCAP::Config optional(:custom) => Hash }, optional(:custom_root_links) => Array, + optional(:service_account_provisioning_enabled) => bool, system_domain: String, optional(:system_domain_organization) => enum(String, NilClass), From 4e64e5147bce64a7758c638031444f35e57fc840 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:48:54 +0200 Subject: [PATCH 15/57] Test account listing description and metadata updates (red) --- spec/request/service_accounts_spec.rb | 57 +++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index b047c42eb84..c778a1d5c09 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -89,4 +89,61 @@ def headers(role) post '/v3/service_accounts', body.to_json, headers('space_manager') expect(last_response.status).to eq(403) end + + it 'creates and updates description and metadata using normal merge/remove semantics' do + auth = headers('space_manager') + post '/v3/service_accounts', body.merge(description: 'Batch payments', metadata: { labels: { team: 'payments' }, annotations: { note: 'original' } }).to_json, auth + expect(last_response.status).to eq(201) + result = Oj.load(last_response.body) + expect(result['description']).to eq('Batch payments') + patch "/v3/service_accounts/#{result['guid']}", { description: 'Revised', metadata: { labels: { team: nil, owner: 'finance' } } }.to_json, auth + expect(last_response.status).to eq(200) + result = Oj.load(last_response.body) + expect(result['description']).to eq('Revised') + expect(result['metadata']).to eq('labels' => { 'owner' => 'finance' }, 'annotations' => { 'note' => 'original' }) + end + + it 'lists only readable accounts with pagination and space/name filters' do + own = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) + VCAP::CloudController::ServiceAccountModel.create(name: 'hidden-worker', space: create(:space)) + get '/v3/service_accounts?per_page=1&names=payments-worker', nil, headers('space_developer') + expect(last_response.status).to eq(200) + result = Oj.load(last_response.body) + expect(result.dig('pagination', 'total_results')).to eq(1) + expect(result['resources'].map { |r| r['guid'] }).to eq([own.guid]) + get "/v3/service_accounts?space_guids=#{space.guid}", nil, headers('admin') + expect(Oj.load(last_response.body)['resources'].map { |r| r['guid'] }).to eq([own.guid]) + end + + it 'lists assigned apps only for account readers' do + own = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) + app = create(:app_model, space: space, service_account: own) + get "/v3/service_accounts/#{own.guid}/apps", nil, headers('space_auditor') + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['resources'].map { |r| r['guid'] }).to eq([app.guid]) + end + + it 'denies developers updates and hides unreadable resources' do + own = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) + patch "/v3/service_accounts/#{own.guid}", { description: 'unauthorized' }.to_json, headers('space_developer') + expect(last_response.status).to eq(403) + hidden = VCAP::CloudController::ServiceAccountModel.create(name: 'hidden-worker', space: create(:space)) + patch "/v3/service_accounts/#{hidden.guid}", { description: 'unauthorized' }.to_json, headers('space_manager') + expect(last_response.status).to eq(404) + end + + %i[name relationships status client_id certificate_dns_san].each do |key| + it "rejects mutation of platform-owned #{key}" do + own = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) + patch "/v3/service_accounts/#{own.guid}", { key => 'injected' }.to_json, headers('space_manager') + expect(last_response.status).to eq(422) + end + end + + it 'rejects invalid metadata and list parameters' do + post '/v3/service_accounts', body.merge(metadata: { labels: { 'invalid/key/key' => 'value' } }).to_json, headers('admin') + expect(last_response.status).to eq(422) + get '/v3/service_accounts?unknown=value', nil, headers('admin') + expect(last_response.status).to eq(400) + end end From 4453c3c140830e405e165176642bd7fab334cace Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:56:05 +0200 Subject: [PATCH 16/57] Add scoped account listing description and metadata APIs --- .../v3/service_accounts_controller.rb | 72 +++++++++++++++++-- .../service_account_create_message.rb | 7 +- .../service_account_update_message.rb | 9 +++ app/messages/service_accounts_list_message.rb | 17 +++++ app/models.rb | 2 + .../service_account_annotation_model.rb | 6 ++ .../runtime/service_account_label_model.rb | 6 ++ app/models/runtime/service_account_model.rb | 3 + .../v3/service_account_presenter.rb | 5 ++ config/routes.rb | 3 + ...1005120200_add_service_account_metadata.rb | 16 +++++ spec/request/service_accounts_spec.rb | 6 +- 12 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 app/messages/service_account_update_message.rb create mode 100644 app/messages/service_accounts_list_message.rb create mode 100644 app/models/runtime/service_account_annotation_model.rb create mode 100644 app/models/runtime/service_account_label_model.rb create mode 100644 db/migrations/20261005120200_add_service_account_metadata.rb diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 5d501c0fdf7..212703c4e5e 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -1,10 +1,30 @@ require 'messages/service_account_create_message' require 'presenters/v3/service_account_presenter' +require 'messages/service_account_update_message' +require 'messages/service_accounts_list_message' +require 'messages/apps_list_message' +require 'presenters/v3/app_presenter' class ServiceAccountsController < ApplicationController + def index + message = ServiceAccountsListMessage.from_params(query_params) + invalid_param!(message.errors.full_messages) unless message.valid? + dataset = ServiceAccountModel.dataset + dataset = dataset.where(space_guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally? + dataset = dataset.where(name: message.names) if message.requested?(:names) + dataset = dataset.where(space_guid: message.space_guids) if message.requested?(:space_guids) + render_list(dataset, message, Presenters::V3::ServiceAccountPresenter, '/v3/service_accounts') + end + + def apps + account = readable_account + message = AppsListMessage.from_params(query_params) + invalid_param!(message.errors.full_messages) unless message.valid? + render_list(account.apps_dataset, message, Presenters::V3::AppPresenter, "/v3/service_accounts/#{account.guid}/apps") + end + def show - account = ServiceAccountModel.where(guid: hashed_params[:guid]).first - resource_not_found!(:service_account) unless account && permission_queryer.can_read_from_space?(account.space.id, account.space.organization_id) + account = readable_account render status: :ok, json: Presenters::V3::ServiceAccountPresenter.new(account) end @@ -15,14 +35,56 @@ def create space = Space.where(guid: message.space_guid).first unprocessable!('Space not found') unless space && permission_queryer.can_read_from_space?(space.id, space.organization_id) - unauthorized! unless permission_queryer.can_write_globally? || space.managers_dataset.where(id: current_user.id).any? - require_writable_space!(space) + authorize_management!(space) - account = ServiceAccountModel.create(name: message.name, space: space) + account = nil + ServiceAccountModel.db.transaction do + account = ServiceAccountModel.create(name: message.name, space: space) + apply_metadata(account, message) + end render status: :created, json: Presenters::V3::ServiceAccountPresenter.new(account) rescue Sequel::ValidationFailed => e raise CloudController::Errors::V3::ApiError.new_from_details('ServiceAccountNameReserved') if e.message.include?('is already reserved') unprocessable!(e.message) end + + def update + account = readable_account + authorize_management!(account.space) + message = ServiceAccountUpdateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + account.db.transaction do + account.lock! + apply_metadata(account, message) + end + render status: :ok, json: Presenters::V3::ServiceAccountPresenter.new(account.reload) + end + + private + + def readable_account + account = ServiceAccountModel.first(guid: hashed_params[:guid]) + resource_not_found!(:service_account) unless account && permission_queryer.can_read_from_space?(account.space.id, account.space.organization_id) + account + end + + def authorize_management!(space) + unauthorized! unless permission_queryer.can_write_globally? || space.managers_dataset.where(id: current_user.id).any? + require_writable_space!(space) + end + + def apply_metadata(account, message) + account.update(description: message.description || '') if message.requested?(:description) + LabelsUpdate.update(account, message.labels, ServiceAccountLabelModel) + AnnotationsUpdate.update(account, message.annotations, ServiceAccountAnnotationModel) + end + + def render_list(dataset, message, presenter, path) + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: presenter, + paginated_result: SequelPaginator.new.get_page(dataset, message.pagination_options), + path: path, message: message + ) + end end diff --git a/app/messages/service_account_create_message.rb b/app/messages/service_account_create_message.rb index caab8ce788c..4c9b0e82a65 100644 --- a/app/messages/service_account_create_message.rb +++ b/app/messages/service_account_create_message.rb @@ -1,8 +1,9 @@ -require 'messages/base_message' +require 'messages/metadata_base_message' module VCAP::CloudController - class ServiceAccountCreateMessage < BaseMessage - register_allowed_keys %i[name relationships] + class ServiceAccountCreateMessage < MetadataBaseMessage + register_allowed_keys %i[name relationships description] + validates :description, string: true, allow_nil: true, length: { maximum: 250 } validates_with NoAdditionalKeysValidator, RelationshipValidator validates :name, presence: true, string: true, format: { with: ->(_) { ServiceAccountModel::NAME_PATTERN } }, length: { in: 3..63 } diff --git a/app/messages/service_account_update_message.rb b/app/messages/service_account_update_message.rb new file mode 100644 index 00000000000..26774a8664b --- /dev/null +++ b/app/messages/service_account_update_message.rb @@ -0,0 +1,9 @@ +require 'messages/metadata_base_message' + +module VCAP::CloudController + class ServiceAccountUpdateMessage < MetadataBaseMessage + register_allowed_keys [:description] + validates_with NoAdditionalKeysValidator + validates :description, string: true, allow_nil: true, length: { maximum: 250 } + end +end diff --git a/app/messages/service_accounts_list_message.rb b/app/messages/service_accounts_list_message.rb new file mode 100644 index 00000000000..bfa960a7f22 --- /dev/null +++ b/app/messages/service_accounts_list_message.rb @@ -0,0 +1,17 @@ +require 'messages/list_message' + +module VCAP::CloudController + class ServiceAccountsListMessage < ListMessage + register_allowed_keys %i[names space_guids] + validates_with NoAdditionalParamsValidator + validates :names, :space_guids, array: true, allow_nil: true + + def self.from_params(params) + super(params, %w[names space_guids]) + end + + def valid_order_by_values + super + [:name] + end + end +end diff --git a/app/models.rb b/app/models.rb index c12eee5f62c..f839d668447 100644 --- a/app/models.rb +++ b/app/models.rb @@ -90,6 +90,8 @@ require 'models/runtime/domain_label_model' require 'models/runtime/droplet_label_model' require 'models/runtime/isolation_segment_label_model' +require 'models/runtime/service_account_label_model' +require 'models/runtime/service_account_annotation_model' require 'models/runtime/organization_label_model' require 'models/runtime/package_label_model' require 'models/runtime/process_label_model' diff --git a/app/models/runtime/service_account_annotation_model.rb b/app/models/runtime/service_account_annotation_model.rb new file mode 100644 index 00000000000..234db2f44bf --- /dev/null +++ b/app/models/runtime/service_account_annotation_model.rb @@ -0,0 +1,6 @@ +module VCAP::CloudController + class ServiceAccountAnnotationModel < Sequel::Model(:service_account_annotations) + many_to_one :service_account, class: 'VCAP::CloudController::ServiceAccountModel', primary_key: :guid, key: :resource_guid, without_guid_generation: true + include MetadataModelMixin + end +end diff --git a/app/models/runtime/service_account_label_model.rb b/app/models/runtime/service_account_label_model.rb new file mode 100644 index 00000000000..15b2e7270e6 --- /dev/null +++ b/app/models/runtime/service_account_label_model.rb @@ -0,0 +1,6 @@ +module VCAP::CloudController + class ServiceAccountLabelModel < Sequel::Model(:service_account_labels) + many_to_one :service_account, class: 'VCAP::CloudController::ServiceAccountModel', primary_key: :guid, key: :resource_guid, without_guid_generation: true + include MetadataModelMixin + end +end diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 54135bf699e..2b13c84920d 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -4,6 +4,9 @@ class ServiceAccountModel < Sequel::Model(:service_accounts) many_to_one :space, key: :space_guid, primary_key: :guid, without_guid_generation: true one_to_many :apps, class: 'VCAP::CloudController::AppModel', key: :service_account_guid, primary_key: :guid + one_to_many :labels, class: 'VCAP::CloudController::ServiceAccountLabelModel', key: :resource_guid, primary_key: :guid + one_to_many :annotations, class: 'VCAP::CloudController::ServiceAccountAnnotationModel', key: :resource_guid, primary_key: :guid + add_association_dependencies labels: :destroy, annotations: :destroy def validate super diff --git a/app/presenters/v3/service_account_presenter.rb b/app/presenters/v3/service_account_presenter.rb index f29cefb0259..4e410709713 100644 --- a/app/presenters/v3/service_account_presenter.rb +++ b/app/presenters/v3/service_account_presenter.rb @@ -1,13 +1,18 @@ require 'presenters/v3/base_presenter' +require 'presenters/mixins/metadata_presentation_helpers' module VCAP::CloudController module Presenters module V3 class ServiceAccountPresenter < BasePresenter + include Mixins::MetadataPresentationHelpers + def to_hash { guid: @resource.guid, name: @resource.name, + description: @resource.description, + metadata: { labels: hashified_labels(@resource.labels), annotations: hashified_annotations(@resource.annotations) }, created_at: @resource.created_at, updated_at: @resource.updated_at, client_id: @resource.client_id, diff --git a/config/routes.rb b/config/routes.rb index e606584a5eb..24a76a7f961 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,9 @@ # apps post '/service_accounts', to: 'service_accounts#create' + get '/service_accounts', to: 'service_accounts#index' + patch '/service_accounts/:guid', to: 'service_accounts#update' + get '/service_accounts/:guid/apps', to: 'service_accounts#apps' get '/service_accounts/:guid', to: 'service_accounts#show' get '/apps/:app_guid/relationships/service_account', to: 'app_service_accounts#show' patch '/apps/:app_guid/relationships/service_account', to: 'app_service_accounts#update' diff --git a/db/migrations/20261005120200_add_service_account_metadata.rb b/db/migrations/20261005120200_add_service_account_metadata.rb new file mode 100644 index 00000000000..31f06b1f531 --- /dev/null +++ b/db/migrations/20261005120200_add_service_account_metadata.rb @@ -0,0 +1,16 @@ +Sequel.migration do + change do + alter_table(:service_accounts) do + add_column :description, String, size: 250, null: false, default: '' + end + create_table(:service_account_labels) do + VCAP::Migration.common(self) + VCAP::Migration.labels_common(self, :service_account_labels, :service_accounts) + end + create_table(:service_account_annotations) do + VCAP::Migration.common(self) + VCAP::Migration.annotations_common(self, :service_account_annotations, :service_accounts) + end + rename_column :service_account_annotations, :key, :key_name + end +end diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index c778a1d5c09..5c60b8dcb1a 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -110,9 +110,9 @@ def headers(role) expect(last_response.status).to eq(200) result = Oj.load(last_response.body) expect(result.dig('pagination', 'total_results')).to eq(1) - expect(result['resources'].map { |r| r['guid'] }).to eq([own.guid]) + expect(result['resources'].pluck('guid')).to eq([own.guid]) get "/v3/service_accounts?space_guids=#{space.guid}", nil, headers('admin') - expect(Oj.load(last_response.body)['resources'].map { |r| r['guid'] }).to eq([own.guid]) + expect(Oj.load(last_response.body)['resources'].pluck('guid')).to eq([own.guid]) end it 'lists assigned apps only for account readers' do @@ -120,7 +120,7 @@ def headers(role) app = create(:app_model, space: space, service_account: own) get "/v3/service_accounts/#{own.guid}/apps", nil, headers('space_auditor') expect(last_response.status).to eq(200) - expect(Oj.load(last_response.body)['resources'].map { |r| r['guid'] }).to eq([app.guid]) + expect(Oj.load(last_response.body)['resources'].pluck('guid')).to eq([app.guid]) end it 'denies developers updates and hides unreadable resources' do From 76bddcaf5e17edd117a84e5f84eb05d5c3654b7c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:57:38 +0200 Subject: [PATCH 17/57] Test account disable enable and safe deletion lifecycle (red) --- spec/request/service_accounts_spec.rb | 80 +++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 5c60b8dcb1a..c7a3a1b32b0 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -146,4 +146,84 @@ def headers(role) get '/v3/service_accounts?unknown=value', nil, headers('admin') expect(last_response.status).to eq(400) end + + context 'account lifecycle' do + let(:account) { VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:provisioner) { VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca: 'identity-ca') } + let(:account_path) { "/v3/service_accounts/#{account.guid}" } + + before do + TestConfig.override(service_account_provisioning_enabled: true) + CloudController::DependencyLocator.instance.register(:service_account_provisioner, provisioner) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + allow(clients).to receive(:delete) + end + + it 'disables new authentication and enables again while retaining explicit principal roles' do + provisioner.provision(account) + principal = VCAP::CloudController::User.first(guid: account.client_id) + org.add_user(principal) + space.add_developer(principal) + registration = provisioner.send(:registration, account) + allow(clients).to receive(:get).and_return(registration) + auth = headers('space_manager') + + patch account_path, { enabled: false }.to_json, auth + expect(last_response.status).to eq(202) + expect(account.reload.enabled).to be(false) + expect(Delayed::Worker.new.work_off).to eq([1, 0]) + expect(clients).to have_received(:delete).with(:client, account.client_id).once + expect(account.reload.status).to eq('disabled') + expect(principal.reload.spaces).to include(space) + + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + patch account_path, { enabled: true }.to_json, auth + expect(last_response.status).to eq(202) + expect(Delayed::Worker.new.work_off).to eq([1, 0]) + expect(account.reload.status).to eq('ready') + expect(principal.reload.spaces).to include(space) + end + + it 'deletes an unused account asynchronously and keeps its name permanently reserved' do + provisioner.provision(account) + allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) + guid = account.guid + auth = headers('space_manager') + delete account_path, nil, auth + expect(last_response.status).to eq(202) + expect(account.reload.status).to eq('deleting') + expect(account.enabled).to be(false) + expect(Delayed::Worker.new.work_off).to eq([1, 0]) + expect(VCAP::CloudController::ServiceAccountModel.first(guid: guid)).to be_nil + expect(VCAP::CloudController::User.first(guid: account.client_id)).to be_nil + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(409) + end + + it 'rejects deleting an account still assigned to an app' do + create(:app_model, space: space, service_account: account) + delete account_path, nil, headers('space_manager') + expect(last_response.status).to eq(409) + expect(account.reload.status).to eq('reserved') + expect(Delayed::Job.count).to eq(0) + end + + it 'denies developer deletion and rejects non-boolean enabled values' do + delete account_path, nil, headers('space_developer') + expect(last_response.status).to eq(403) + patch account_path, { enabled: 'false' }.to_json, headers('space_manager') + expect(last_response.status).to eq(422) + end + + it 'refuses to delete an unmanaged colliding client' do + allow(clients).to receive(:get).and_return('client_id' => account.client_id) + delete account_path, nil, headers('space_manager') + expect(last_response.status).to eq(202) + expect(Delayed::Worker.new.work_off).to eq([0, 1]) + expect(clients).not_to have_received(:delete) + expect(account.reload.status).to eq('failed') + end + end end From 9d62b7bc9d6699e7b302559347210c24c2f1a263 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:00:26 +0200 Subject: [PATCH 18/57] Reconcile account disable enable and unused-account deletion --- app/actions/service_account_provision.rb | 33 +++++++++++++++ .../v3/service_accounts_controller.rb | 42 +++++++++++++++++++ app/jobs/v3/service_account_provision.rb | 15 +++++-- .../service_account_update_message.rb | 3 +- config/routes.rb | 1 + .../uaa/service_account_client.rb | 6 +++ 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index f920a29b826..07e42d76522 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -39,6 +39,39 @@ def provision(account) account end + def deprovision(account, delete: false) + failure = nil + account.db.transaction(savepoint: true) do + account.lock! + raise Conflict.new('service account was enabled again') if account.enabled + raise Conflict.new('service account is still assigned') if delete && account.apps_dataset.any? + + begin + account.db.transaction(savepoint: true) do + principal = User.first(guid: account.client_id) + raise Conflict.new('principal identity collision') if principal && !principal.is_oauth_client + + existing = existing_client(account.client_id) + if existing + raise Conflict.new('client identity collision') unless matching_registration?(existing, registration(account)) + + @clients.delete(:client, account.client_id) + end + if delete + principal&.destroy + account.destroy + else + account.update(status: 'disabled') + end + end + rescue StandardError => e + failure = e + account.reload.update(status: 'failed') + end + end + raise failure if failure + end + private def matching_registration?(existing, desired) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 212703c4e5e..cb60f1a5b45 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -54,15 +54,57 @@ def update authorize_management!(account.space) message = ServiceAccountUpdateMessage.new(hashed_params[:body]) unprocessable!(message.errors.full_messages) unless message.valid? + job = nil account.db.transaction do account.lock! apply_metadata(account, message) + if message.requested?(:enabled) + require_provisioning! + reject_active_operation!(account) + account.update(enabled: message.enabled, status: 'reconciling') + job = enqueue_lifecycle(account, message.enabled ? 'provision' : 'disable') + end end + return head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}") if job + render status: :ok, json: Presenters::V3::ServiceAccountPresenter.new(account.reload) end + def destroy + account = readable_account + authorize_management!(account.space) + require_provisioning! + job = nil + account.db.transaction do + account.lock! + lifecycle_conflict!('service account is still assigned') if account.apps_dataset.any? + reject_active_operation!(account) + account.update(enabled: false, status: 'deleting') + job = enqueue_lifecycle(account, 'delete') + end + head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}") + end + private + def require_provisioning! + lifecycle_conflict!('service account provisioning is not enabled') unless Config.config.get(:service_account_provisioning_enabled) == true + end + + def reject_active_operation!(account) + return unless PollableJobModel.where(resource_guid: account.guid, resource_type: 'service_account', state: %w[PROCESSING POLLING]).any? + + lifecycle_conflict!('service account operation is already in progress') + end + + def lifecycle_conflict!(detail) + raise CloudController::Errors::V3::ApiError.new_from_details('ServiceAccountAssignmentConflict', detail) + end + + def enqueue_lifecycle(account, operation) + Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(Jobs::V3::ServiceAccountProvision.new(account.guid, operation: operation)) + end + def readable_account account = ServiceAccountModel.first(guid: hashed_params[:guid]) resource_not_found!(:service_account) unless account && permission_queryer.can_read_from_space?(account.space.id, account.space.organization_id) diff --git a/app/jobs/v3/service_account_provision.rb b/app/jobs/v3/service_account_provision.rb index f82ce472d8d..756ad02fad8 100644 --- a/app/jobs/v3/service_account_provision.rb +++ b/app/jobs/v3/service_account_provision.rb @@ -7,15 +7,24 @@ module V3 class ServiceAccountProvision < CCJob attr_reader :resource_guid - def initialize(account_guid) + def initialize(account_guid, operation: 'provision') @resource_guid = account_guid + @operation = operation end def perform account = ServiceAccountModel.first(guid: resource_guid) raise CloudController::Errors::ApiError.new_from_details('ResourceNotFound', 'The service account could not be found') unless account - CloudController::DependencyLocator.instance.service_account_provisioner.provision(account) + provisioner = CloudController::DependencyLocator.instance.service_account_provisioner + case @operation + when 'provision' + provisioner.provision(account) + when 'disable', 'delete' + provisioner.deprovision(account, delete: @operation == 'delete') + else + raise ArgumentError.new('unsupported service account operation') + end end def max_attempts @@ -27,7 +36,7 @@ def resource_type end def display_name - 'service_account.provision' + "service_account.#{@operation}" end def job_name_in_configuration diff --git a/app/messages/service_account_update_message.rb b/app/messages/service_account_update_message.rb index 26774a8664b..6bc199effe0 100644 --- a/app/messages/service_account_update_message.rb +++ b/app/messages/service_account_update_message.rb @@ -2,7 +2,8 @@ module VCAP::CloudController class ServiceAccountUpdateMessage < MetadataBaseMessage - register_allowed_keys [:description] + register_allowed_keys %i[description enabled] + validates :enabled, boolean: true, if: -> { requested?(:enabled) } validates_with NoAdditionalKeysValidator validates :description, string: true, allow_nil: true, length: { maximum: 250 } end diff --git a/config/routes.rb b/config/routes.rb index 24a76a7f961..612c691e9fd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,7 @@ post '/service_accounts', to: 'service_accounts#create' get '/service_accounts', to: 'service_accounts#index' patch '/service_accounts/:guid', to: 'service_accounts#update' + delete '/service_accounts/:guid', to: 'service_accounts#destroy' get '/service_accounts/:guid/apps', to: 'service_accounts#apps' get '/service_accounts/:guid', to: 'service_accounts#show' get '/apps/:app_guid/relationships/service_account', to: 'app_service_accounts#show' diff --git a/lib/cloud_controller/uaa/service_account_client.rb b/lib/cloud_controller/uaa/service_account_client.rb index f2b15fb7dec..81646f6cc0a 100644 --- a/lib/cloud_controller/uaa/service_account_client.rb +++ b/lib/cloud_controller/uaa/service_account_client.rb @@ -13,5 +13,11 @@ def add(type, registration) with_cache_retry { scim.add(type, registration) } end + + def delete(type, id) + raise ArgumentError.new('only client resources are supported') unless type == :client + + with_cache_retry { scim.delete(type, id) } + end end end From 69b9638fbb7e212267521368992e592ff76b1d41 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:01:57 +0200 Subject: [PATCH 19/57] Test service account and assignment audit events (red) --- spec/request/app_service_accounts_spec.rb | 4 ++++ spec/request/service_accounts_spec.rb | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index bf88b7b4752..07db04402c9 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -18,6 +18,9 @@ def headers(role) expect(last_response.headers['X-Cf-Warnings']).to include('Restart') expect(app_model.reload.service_account).to eq(account) expect(app_model.desired_state).to eq('STARTED') + event = VCAP::CloudController::Event.first(type: 'audit.app.service_account.assign', actee: app_model.guid) + expect(event).not_to be_nil + expect(event.metadata['service_account_guid']).to eq(account.guid) end it 'shows an empty relationship for an unbound app' do @@ -53,6 +56,7 @@ def headers(role) expect(last_response.status).to eq(200) expect(app_model.reload.service_account).to be_nil expect(last_response.headers['X-Cf-Warnings']).to include('Restart') + expect(VCAP::CloudController::Event.where(type: 'audit.app.service_account.unassign', actee: app_model.guid).count).to eq(1) end it 'rejects assignment while the space is suspended' do diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index c7a3a1b32b0..2dcc005cf71 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -19,6 +19,10 @@ def headers(role) 'client_id' => 'cf:service-account:payments-worker', 'certificate_dns_san' => 'payments-worker.svc.identity') expect(result.dig('relationships', 'space', 'data', 'guid')).to eq(space.guid) expect(VCAP::CloudController::ServiceAccountModel.where(guid: result['guid']).first.space_guid).to eq(space.guid) + event = VCAP::CloudController::Event.first(type: 'audit.service_account.create', actee: result['guid']) + expect(event).not_to be_nil + expect(event.actor).to eq(user.guid) + expect(event.space_guid).to eq(space.guid) end it 'allows a platform administrator to create an account' do @@ -101,6 +105,7 @@ def headers(role) result = Oj.load(last_response.body) expect(result['description']).to eq('Revised') expect(result['metadata']).to eq('labels' => { 'owner' => 'finance' }, 'annotations' => { 'note' => 'original' }) + expect(VCAP::CloudController::Event.where(type: 'audit.service_account.update', actee: result['guid']).count).to eq(1) end it 'lists only readable accounts with pagination and space/name filters' do From e3d62b83e260c8ab38ad0edf7149091a9a8b7abc Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:03:41 +0200 Subject: [PATCH 20/57] Audit service account lifecycle and assignment changes --- app/actions/app_assign_service_account.rb | 18 ++++++++++++++-- .../v3/app_service_accounts_controller.rb | 2 +- .../v3/service_accounts_controller.rb | 4 ++++ .../service_account_event_repository.rb | 21 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 app/repositories/service_account_event_repository.rb diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 7896a906feb..8b8d69bf361 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -1,11 +1,14 @@ +require 'repositories/service_account_event_repository' + module VCAP::CloudController class AppAssignServiceAccount class Error < StandardError; end class Unauthorized < Error; end class Conflict < Error; end - def initialize(permissions) + def initialize(permissions, actor: nil) @permissions = permissions + @actor = actor end def assign(app, account, provision: false) @@ -24,13 +27,24 @@ def assign(app, account, provision: false) job = provision_account(account) unless account.status == 'ready' end - app.update(service_account: account) unless app.service_account_guid == account&.guid + unless app.service_account_guid == account&.guid + previous_guid = app.service_account_guid + app.update(service_account: account) + record_assignment(app, account, previous_guid) if @actor + end end provision ? job : app end private + def record_assignment(app, account, previous_guid) + Repositories::ServiceAccountEventRepository.record( + app, account ? 'assign' : 'unassign', @actor, + service_account_guid: account&.guid || previous_guid + ) + end + def validate_ready!(account, provision) raise Conflict.new('service account is not ready or enabled') unless account.enabled && (account.status == 'ready' || provision) end diff --git a/app/controllers/v3/app_service_accounts_controller.rb b/app/controllers/v3/app_service_accounts_controller.rb index d5f3c6cd707..b0690fa1e37 100644 --- a/app/controllers/v3/app_service_accounts_controller.rb +++ b/app/controllers/v3/app_service_accounts_controller.rb @@ -18,7 +18,7 @@ def update account = ServiceAccountModel.where(guid: message.account_guid).first if message.account_guid resource_not_found!(:service_account) if message.account_guid && !account - job = AppAssignServiceAccount.new(permission_queryer).assign(app, account, provision: Config.config.get(:service_account_provisioning_enabled) == true) + job = AppAssignServiceAccount.new(permission_queryer, actor: user_audit_info).assign(app, account, provision: Config.config.get(:service_account_provisioning_enabled) == true) add_warning_headers(["Restart #{app.name} for the service-account assignment change to take effect."]) return head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}") if job.is_a?(PollableJobModel) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index cb60f1a5b45..fce64ce4450 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -4,6 +4,7 @@ require 'messages/service_accounts_list_message' require 'messages/apps_list_message' require 'presenters/v3/app_presenter' +require 'repositories/service_account_event_repository' class ServiceAccountsController < ApplicationController def index @@ -41,6 +42,7 @@ def create ServiceAccountModel.db.transaction do account = ServiceAccountModel.create(name: message.name, space: space) apply_metadata(account, message) + Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info) end render status: :created, json: Presenters::V3::ServiceAccountPresenter.new(account) rescue Sequel::ValidationFailed => e @@ -64,6 +66,7 @@ def update account.update(enabled: message.enabled, status: 'reconciling') job = enqueue_lifecycle(account, message.enabled ? 'provision' : 'disable') end + Repositories::ServiceAccountEventRepository.record(account, 'update', user_audit_info, message.audit_hash) end return head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}") if job @@ -81,6 +84,7 @@ def destroy reject_active_operation!(account) account.update(enabled: false, status: 'deleting') job = enqueue_lifecycle(account, 'delete') + Repositories::ServiceAccountEventRepository.record(account, 'delete', user_audit_info) end head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}") end diff --git a/app/repositories/service_account_event_repository.rb b/app/repositories/service_account_event_repository.rb new file mode 100644 index 00000000000..4e3077d2af0 --- /dev/null +++ b/app/repositories/service_account_event_repository.rb @@ -0,0 +1,21 @@ +module VCAP::CloudController + module Repositories + class ServiceAccountEventRepository + def self.record(resource, operation, actor, metadata={}) + Event.create( + type: "audit.#{resource.is_a?(AppModel) ? 'app.service_account' : 'service_account'}.#{operation}", + space: resource.space, + actee: resource.guid, + actee_type: resource.is_a?(AppModel) ? 'app' : 'service_account', + actee_name: resource.name, + actor: actor.user_guid, + actor_type: 'user', + actor_name: actor.user_email, + actor_username: actor.user_name, + timestamp: Sequel::CURRENT_TIMESTAMP, + metadata: metadata + ) + end + end + end +end From 3f59b0d221df55e70f54ad1ef09aec2fe72ce136 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:07:07 +0200 Subject: [PATCH 21/57] Test explicit account roles without human UAA lookup (red) --- spec/request/service_account_roles_spec.rb | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 spec/request/service_account_roles_spec.rb diff --git a/spec/request/service_account_roles_spec.rb b/spec/request/service_account_roles_spec.rb new file mode 100644 index 00000000000..37485b203a3 --- /dev/null +++ b/spec/request/service_account_roles_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +RSpec.describe 'Explicit service account resource roles' do + let(:space) { create(:space) } + let(:admin) { create(:user) } + let(:account) { VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:principal) { VCAP::CloudController::User.first(guid: account.client_id) } + + before do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca: 'identity-ca').provision(account) + lookup = instance_double(VCAP::CloudController::UaaClient, usernames_for_ids: {}) + expect(lookup).not_to receive(:usernames_for_ids).with([principal.guid]) + allow(CloudController::DependencyLocator.instance).to receive(:uaa_username_lookup_client).and_return(lookup) + end + + def grant(type, resource, guid) + post '/v3/roles', { + type: type, + relationships: { user: { data: { guid: principal.guid } }, resource => { data: { guid: guid } } } + }.to_json, admin_headers_for(admin) + end + + it 'requires explicit organization membership and then a space role before account tokens can write apps' do + grant('space_developer', :space, space.guid) + expect(last_response.status).to eq(422) + grant('organization_user', :organization, space.organization.guid) + expect(last_response.status).to eq(201) + + app_body = { name: 'account-created', relationships: { space: { data: { guid: space.guid } } } } + post '/v3/apps', app_body.to_json, headers_for(principal, client: true) + expect(last_response.status).to eq(422) + + grant('space_developer', :space, space.guid) + expect(last_response.status).to eq(201) + post '/v3/apps', app_body.to_json, headers_for(principal, client: true) + expect(last_response.status).to eq(201) + + post '/v3/service_accounts', { name: 'privilege-escalation', relationships: { space: { data: { guid: space.guid } } } }.to_json, headers_for(principal, client: true) + expect(last_response.status).to eq(403) + end + + it 'keeps account tokens denied in unrelated spaces after an explicit role grant' do + grant('organization_user', :organization, space.organization.guid) + grant('space_developer', :space, space.guid) + other = create(:space, organization: space.organization) + post '/v3/apps', { name: 'unauthorized', relationships: { space: { data: { guid: other.guid } } } }.to_json, headers_for(principal, client: true) + expect(last_response.status).to eq(422) + end +end From b520f3a8a5fca16f80d4d4cdfeaa7f6598e87a5e Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:08:41 +0200 Subject: [PATCH 22/57] Resolve managed account names locally for explicit role APIs --- app/collection_transformers/username_populator.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/collection_transformers/username_populator.rb b/app/collection_transformers/username_populator.rb index 866baf3e145..99892d72a54 100644 --- a/app/collection_transformers/username_populator.rb +++ b/app/collection_transformers/username_populator.rb @@ -8,9 +8,11 @@ def initialize(uaa_client) def transform(users, _opts={}) users = Array(users) - user_ids = users.collect(&:guid) - username_mapping = uaa_client.usernames_for_ids(user_ids) - users.each { |user| user.username = username_mapping[user.guid] } + accounts = ServiceAccountModel.where(name: users.select(&:is_oauth_client).map { |user| user.guid.delete_prefix('cf:service-account:') }).all + account_names = accounts.to_h { |account| [account.client_id, account.name] } + human_users = users.reject { |user| user.is_oauth_client && account_names.key?(user.guid) } + username_mapping = human_users.empty? ? {} : uaa_client.usernames_for_ids(human_users.collect(&:guid)) + users.each { |user| user.username = user.is_oauth_client && account_names.key?(user.guid) ? account_names[user.guid] : username_mapping[user.guid] } end end end From b3fbf7797b49e307fa2d0b065e9e1e00d038061c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:20:35 +0200 Subject: [PATCH 23/57] Test typed runtime identity and fail-closed account gates (red) --- .../diego/service_account_identity_spec.rb | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb diff --git a/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb b/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb new file mode 100644 index 00000000000..ced385f81af --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' +require 'cloud_controller/diego/service_account_identity' if File.exist?('lib/cloud_controller/diego/service_account_identity.rb') + +module VCAP::CloudController::Diego + RSpec.describe 'Service account runtime identity' do + let(:app) { create(:app_model) } + let(:account) { VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') } + let(:identity) { VCAP::CloudController::Diego.const_get(:ServiceAccountIdentity).new(app, TestConfig.config_instance) } + + before do + TestConfig.override(service_account_runtime_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') + app.update(service_account: account) + end + + it 'serializes a typed launch identity while preserving organizational units' do + properties = identity.certificate_properties + decoded = ::Diego::Bbs::Models::CertificateProperties.decode(properties.to_proto) + expect(decoded.service_account.name).to eq(account.name) + expect(decoded.organizational_unit).to eq(["organization:#{app.organization_guid}", "space:#{app.space_guid}", "app:#{app.guid}"]) + app.update(service_account: nil) + expect(decoded.service_account.name).to eq(account.name) + end + + it 'exposes non-secret discovery information' do + expect(identity.environment).to eq('VCAP_SERVICE_ACCOUNT' => { + guid: account.guid, name: account.name, client_id: account.client_id, certificate_dns_san: account.certificate_dns_san, + token_endpoint: 'https://uaa.example.test/oauth/token/mtls' + }) + end + + %w[reserved reconciling failed disabled deleting].each do |state| + it "rejects new runtime credentials in #{state} state" do + account.update(status: state) + expect { identity.certificate_properties }.to raise_error(CloudController::Errors::ApiError, /not ready/) + end + end + + it 'rejects a disabled ready account and a mixed-version disabled runtime' do + account.update(enabled: false) + expect { identity.certificate_properties }.to raise_error(CloudController::Errors::ApiError, /not ready/) + account.update(enabled: true) + TestConfig.override(service_account_runtime_enabled: false) + expect { identity.certificate_properties }.to raise_error(CloudController::Errors::ApiError, /runtime is not enabled/) + end + + it 'does not give unbound apps account SANs or metadata' do + app.update(service_account: nil) + expect(identity.certificate_properties.to_h).not_to have_key(:service_account) + expect(identity.environment).to eq({}) + end + end +end From 7b491b000240077bb40b9a4983095a12c456d0a5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:24:20 +0200 Subject: [PATCH 24/57] Build typed ready-account runtime identity and discovery metadata --- .../config_schemas/api_schema.rb | 2 + .../config_schemas/clock_schema.rb | 2 + .../config_schemas/worker_schema.rb | 2 + .../diego/service_account_identity.rb | 49 +++++++++++++++++++ .../bbs/models/certificate_properties_pb.rb | 5 +- .../diego/service_account_identity_spec.rb | 7 +-- 6 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 lib/cloud_controller/diego/service_account_identity.rb diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index 6dd200dd240..bf3fb0630a0 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -31,6 +31,8 @@ class ApiSchema < VCAP::Config }, optional(:custom_root_links) => Array, optional(:service_account_provisioning_enabled) => bool, + optional(:service_account_runtime_enabled) => bool, + optional(:service_account_token_endpoint) => String, system_domain: String, optional(:system_domain_organization) => enum(String, NilClass), diff --git a/lib/cloud_controller/config_schemas/clock_schema.rb b/lib/cloud_controller/config_schemas/clock_schema.rb index 4aa96c33bc4..d3d6c3dd968 100644 --- a/lib/cloud_controller/config_schemas/clock_schema.rb +++ b/lib/cloud_controller/config_schemas/clock_schema.rb @@ -6,6 +6,8 @@ class ClockSchema < VCAP::Config # rubocop:disable Metrics/BlockLength define_schema do { + optional(:service_account_runtime_enabled) => bool, + optional(:service_account_token_endpoint) => String, external_port: Integer, external_domain: String, tls_port: Integer, diff --git a/lib/cloud_controller/config_schemas/worker_schema.rb b/lib/cloud_controller/config_schemas/worker_schema.rb index e9ee3d1ac9d..c8803229ba8 100644 --- a/lib/cloud_controller/config_schemas/worker_schema.rb +++ b/lib/cloud_controller/config_schemas/worker_schema.rb @@ -34,6 +34,8 @@ class WorkerSchema < VCAP::Config client_secret: String, identity_ca: String }, + optional(:service_account_runtime_enabled) => bool, + optional(:service_account_token_endpoint) => String, logging: { level: String, # debug, info, etc. diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb new file mode 100644 index 00000000000..6ffd47ddfaf --- /dev/null +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -0,0 +1,49 @@ +module VCAP::CloudController + module Diego + class ServiceAccountIdentity + def initialize(app, config) + @app = app + @config = config + end + + def certificate_properties + account = ready_account + attributes = { organizational_unit: ["organization:#{@app.organization_guid}", "space:#{@app.space_guid}", "app:#{@app.guid}"] } + attributes[:service_account] = ::Diego::Bbs::Models::ServiceAccount.new(name: account.name) if account + ::Diego::Bbs::Models::CertificateProperties.new(attributes) + end + + def environment + account = ready_account + return {} unless account + + endpoint = @config.get(:service_account_token_endpoint) + unless endpoint.is_a?(String) && endpoint.start_with?('https://') + raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account token endpoint is not configured') + end + + { 'VCAP_SERVICE_ACCOUNT' => { + guid: account.guid, name: account.name, client_id: account.client_id, + certificate_dns_san: account.certificate_dns_san, token_endpoint: endpoint + } } + end + + private + + def ready_account + return unless @app.service_account_guid + + unless @config.get(:service_account_runtime_enabled) == true + raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account runtime is not enabled') + end + + account = ServiceAccountModel.first(guid: @app.service_account_guid) + unless account && account.enabled && account.status == 'ready' && account.space_guid == @app.space_guid + raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account is not ready for runtime credentials') + end + + account + end + end + end +end diff --git a/lib/diego/bbs/models/certificate_properties_pb.rb b/lib/diego/bbs/models/certificate_properties_pb.rb index 003d022b902..16355dfbcd3 100644 --- a/lib/diego/bbs/models/certificate_properties_pb.rb +++ b/lib/diego/bbs/models/certificate_properties_pb.rb @@ -5,15 +5,16 @@ require 'google/protobuf' -descriptor_data = "\n\x1c\x63\x65rtificate_properties.proto\x12\x10\x64iego.bbs.models\"4\n\x15\x43\x65rtificateProperties\x12\x1b\n\x13organizational_unit\x18\x01 \x03(\tb\x06proto3" +descriptor_data = "\n\x1c\x63\x65rtificate_properties.proto\x12\x10\x64iego.bbs.models\"o\n\x15\x43\x65rtificateProperties\x12\x1b\n\x13organizational_unit\x18\x01 \x03(\t\x12\x39\n\x0fservice_account\x18\x02 \x01(\x0b\x32 .diego.bbs.models.ServiceAccount\"\x1e\n\x0eServiceAccount\x12\x0c\n\x04name\x18\x01 \x01(\tb\x06proto3" -pool = Google::Protobuf::DescriptorPool.generated_pool +pool = ::Google::Protobuf::DescriptorPool.generated_pool pool.add_serialized_file(descriptor_data) module Diego module Bbs module Models CertificateProperties = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.CertificateProperties").msgclass + ServiceAccount = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.ServiceAccount").msgclass end end end diff --git a/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb b/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb index ced385f81af..bf26ab4f111 100644 --- a/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb @@ -22,10 +22,11 @@ module VCAP::CloudController::Diego end it 'exposes non-secret discovery information' do - expect(identity.environment).to eq('VCAP_SERVICE_ACCOUNT' => { + metadata = { guid: account.guid, name: account.name, client_id: account.client_id, certificate_dns_san: account.certificate_dns_san, token_endpoint: 'https://uaa.example.test/oauth/token/mtls' - }) + } + expect(identity.environment).to eq('VCAP_SERVICE_ACCOUNT' => metadata) end %w[reserved reconciling failed disabled deleting].each do |state| @@ -39,7 +40,7 @@ module VCAP::CloudController::Diego account.update(enabled: false) expect { identity.certificate_properties }.to raise_error(CloudController::Errors::ApiError, /not ready/) account.update(enabled: true) - TestConfig.override(service_account_runtime_enabled: false) + TestConfig.config[:service_account_runtime_enabled] = false expect { identity.certificate_properties }.to raise_error(CloudController::Errors::ApiError, /runtime is not enabled/) end From d1a07f4d0c7bd3d519efd9ceae541300625eec14 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:28:04 +0200 Subject: [PATCH 25/57] Test runtime and task account propagation boundaries (red) --- .../lib/cloud_controller/diego/environment_spec.rb | 14 ++++++++++++++ .../diego/task_environment_spec.rb | 8 ++++++++ .../diego/task_recipe_builder_spec.rb | 7 +++++++ 3 files changed, 29 insertions(+) diff --git a/spec/unit/lib/cloud_controller/diego/environment_spec.rb b/spec/unit/lib/cloud_controller/diego/environment_spec.rb index 93642fabd9b..bcb039d6fc7 100644 --- a/spec/unit/lib/cloud_controller/diego/environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/environment_spec.rb @@ -39,6 +39,20 @@ module VCAP::CloudController::Diego ]) end + it 'overrides injected account discovery with the ready platform account' do + TestConfig.override(service_account_runtime_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') + account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: process.space, status: 'ready') + process.app.update(service_account: account) + environment['VCAP_SERVICE_ACCOUNT'] = 'injected' + value = Environment.new(process).as_json.find { |entry| entry['name'] == 'VCAP_SERVICE_ACCOUNT' }['value'] + expect(Oj.load(value)).to include('name' => account.name, 'client_id' => account.client_id) + end + + it 'removes injected account discovery from unbound apps' do + environment['VCAP_SERVICE_ACCOUNT'] = 'injected' + expect(Environment.new(process).as_json.pluck('name')).not_to include('VCAP_SERVICE_ACCOUNT') + end + context 'when the user specifies their own MEMORY_LIMIT' do it 'uses the system provided MEMORY_LIMIT' do environment['MEMORY_LIMIT'] = 'i-should-not-be-usedMB' diff --git a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb index 48bc645d0ba..b6c4b7ad8a5 100644 --- a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb @@ -37,6 +37,14 @@ module VCAP::CloudController::Diego end describe '#build' do + it 'inherits the ready platform account discovery and overrides injected values' do + TestConfig.override(service_account_runtime_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') + account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') + app.update(service_account: account) + env = TaskEnvironment.new(app, task, space, { 'VCAP_SERVICE_ACCOUNT' => 'injected' }).build + expect(env['VCAP_SERVICE_ACCOUNT']).to include(name: account.name, client_id: account.client_id) + end + before do TestConfig.config[:instance_file_descriptor_limit] = 100 TestConfig.config[:default_app_disk_in_mb] = staging_disk_in_mb diff --git a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb index 9daaf547724..d429a742273 100644 --- a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb @@ -500,6 +500,13 @@ module Diego end context 'with a buildpack backend' do + it 'propagates a ready service account as typed task certificate properties' do + config.config_hash[:service_account_runtime_enabled] = true + account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') + app.update(service_account: account) + expect(task_recipe_builder.build_app_task(config, task).certificate_properties.service_account.name).to eq(account.name) + end + let(:droplet) { create(:droplet_model, app:) } let(:task_action_builder) do From 570e417ca243bb286aa1d1bd8e82860fa01ceb9d Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:31:09 +0200 Subject: [PATCH 26/57] Propagate account identity to runtime recipes and discovery --- lib/cloud_controller/diego/app_recipe_builder.rb | 5 ++--- lib/cloud_controller/diego/environment.rb | 4 ++++ lib/cloud_controller/diego/task_environment.rb | 4 ++++ lib/cloud_controller/diego/task_recipe_builder.rb | 9 ++------- .../diego/task_environment_spec.rb | 10 +++++----- .../diego/task_recipe_builder_spec.rb | 14 +++++++------- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/lib/cloud_controller/diego/app_recipe_builder.rb b/lib/cloud_controller/diego/app_recipe_builder.rb index 25a4c10866f..f9513e43b00 100644 --- a/lib/cloud_controller/diego/app_recipe_builder.rb +++ b/lib/cloud_controller/diego/app_recipe_builder.rb @@ -10,6 +10,7 @@ require 'credhub/config_helpers' require 'models/helpers/health_check_types' require 'cloud_controller/diego/main_lrp_action_builder' +require 'cloud_controller/diego/service_account_identity' module VCAP::CloudController module Diego @@ -98,9 +99,7 @@ def app_lrp_arguments check_definition: generate_healthcheck_definition(desired_lrp_builder), routes: ::Diego::Bbs::Models::ProtoRoutes.new(routes:), max_pids: @config.get(:diego, :pid_limit), - certificate_properties: ::Diego::Bbs::Models::CertificateProperties.new( - organizational_unit: ["organization:#{process.organization.guid}", "space:#{process.space.guid}", "app:#{process.app_guid}"] - ), + certificate_properties: ServiceAccountIdentity.new(process.app, config).certificate_properties, image_username: process.desired_droplet.docker_receipt_username, image_password: process.desired_droplet.docker_receipt_password, volume_mounted_files: ServiceBindingFilesBuilder.build(process) diff --git a/lib/cloud_controller/diego/environment.rb b/lib/cloud_controller/diego/environment.rb index d5789782432..fe7a04fe18d 100644 --- a/lib/cloud_controller/diego/environment.rb +++ b/lib/cloud_controller/diego/environment.rb @@ -1,6 +1,7 @@ require 'presenters/system_environment/system_env_presenter' require 'cloud_controller/diego/normal_env_hash_to_diego_env_array_philosopher' require_relative '../../vcap/vars_builder' +require 'cloud_controller/diego/service_account_identity' module VCAP::CloudController module Diego @@ -43,6 +44,9 @@ def common_json_and_merge(&blk) merge(blk.call). merge(SystemEnvPresenter.new(process).system_env) + diego_env = diego_env.except('VCAP_SERVICE_ACCOUNT', :VCAP_SERVICE_ACCOUNT). + merge(ServiceAccountIdentity.new(process.app, Config.config).environment) + diego_env = diego_env.merge(DATABASE_URL: process.database_uri) if process.database_uri NormalEnvHashToDiegoEnvArrayPhilosopher.muse(diego_env) diff --git a/lib/cloud_controller/diego/task_environment.rb b/lib/cloud_controller/diego/task_environment.rb index dad26816613..c298015bd84 100644 --- a/lib/cloud_controller/diego/task_environment.rb +++ b/lib/cloud_controller/diego/task_environment.rb @@ -1,4 +1,5 @@ require 'credhub/config_helpers' +require 'cloud_controller/diego/service_account_identity' module VCAP::CloudController module Diego @@ -21,6 +22,9 @@ def build merge('VCAP_APPLICATION' => vcap_application, 'MEMORY_LIMIT' => "#{task.memory_in_mb}m"). merge(SystemEnvPresenter.new(app).system_env.stringify_keys) + task_env = task_env.except('VCAP_SERVICE_ACCOUNT', :VCAP_SERVICE_ACCOUNT). + merge(ServiceAccountIdentity.new(app, Config.config).environment) + task_env = task_env.merge('VCAP_PLATFORM_OPTIONS' => credhub_url) if credhub_url.present? && cred_interpolation_enabled? task_env = task_env.merge('LANG' => DEFAULT_LANG) if [BuildpackLifecycleDataModel::LIFECYCLE_TYPE, CNBLifecycleDataModel::LIFECYCLE_TYPE].include?(app.lifecycle_type) diff --git a/lib/cloud_controller/diego/task_recipe_builder.rb b/lib/cloud_controller/diego/task_recipe_builder.rb index f1a4962095d..d541d2012cd 100644 --- a/lib/cloud_controller/diego/task_recipe_builder.rb +++ b/lib/cloud_controller/diego/task_recipe_builder.rb @@ -6,6 +6,7 @@ require 'cloud_controller/diego/task_completion_callback_generator' require 'cloud_controller/diego/task_cpu_weight_calculator' require 'cloud_controller/diego/service_binding_files_builder' +require 'cloud_controller/diego/service_account_identity' module VCAP::CloudController module Diego @@ -45,13 +46,7 @@ def build_app_task(config, task) root_fs: task_action_builder.stack, environment_variables: task_action_builder.task_environment_variables, placement_tags: [VCAP::CloudController::IsolationSegmentSelector.for_space(task.space)].compact, - certificate_properties: ::Diego::Bbs::Models::CertificateProperties.new( - organizational_unit: [ - "organization:#{task.app.organization.guid}", - "space:#{task.app.space_guid}", - "app:#{task.app_guid}" - ] - ), + certificate_properties: ServiceAccountIdentity.new(task.app, config).certificate_properties, image_username: task.droplet.docker_receipt_username, image_password: task.droplet.docker_receipt_password, volume_mounted_files: ServiceBindingFilesBuilder.build(task.app) diff --git a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb index b6c4b7ad8a5..a2221a06df4 100644 --- a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb @@ -37,6 +37,11 @@ module VCAP::CloudController::Diego end describe '#build' do + before do + TestConfig.config[:instance_file_descriptor_limit] = 100 + TestConfig.config[:default_app_disk_in_mb] = staging_disk_in_mb + end + it 'inherits the ready platform account discovery and overrides injected values' do TestConfig.override(service_account_runtime_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') @@ -45,11 +50,6 @@ module VCAP::CloudController::Diego expect(env['VCAP_SERVICE_ACCOUNT']).to include(name: account.name, client_id: account.client_id) end - before do - TestConfig.config[:instance_file_descriptor_limit] = 100 - TestConfig.config[:default_app_disk_in_mb] = staging_disk_in_mb - end - it 'returns the correct environment hash for a v3 app' do constructed_envs = TaskEnvironment.new(app, task, space).build diff --git a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb index d429a742273..11c7a442932 100644 --- a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb @@ -500,13 +500,6 @@ module Diego end context 'with a buildpack backend' do - it 'propagates a ready service account as typed task certificate properties' do - config.config_hash[:service_account_runtime_enabled] = true - account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') - app.update(service_account: account) - expect(task_recipe_builder.build_app_task(config, task).certificate_properties.service_account.name).to eq(account.name) - end - let(:droplet) { create(:droplet_model, app:) } let(:task_action_builder) do @@ -531,6 +524,13 @@ module Diego allow(TaskCpuWeightCalculator).to receive(:new).with(memory_in_mb: task.memory_in_mb).and_return(calculator) end + it 'propagates a ready service account as typed task certificate properties' do + config.config_hash[:service_account_runtime_enabled] = true + account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') + app.update(service_account: account) + expect(task_recipe_builder.build_app_task(config, task).certificate_properties.service_account.name).to eq(account.name) + end + it 'constructs a TaskDefinition with app task instructions' do result = task_recipe_builder.build_app_task(config, task) expected_callback_url = "https://#{internal_service_hostname}:#{tls_port}/internal/v4/tasks/#{task.guid}/completed" From 527812ff4e658bada65740f7cc6ce5994f89b6f1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:32:34 +0200 Subject: [PATCH 27/57] Test task account identity snapshot at creation (red) --- spec/unit/actions/task_create_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/unit/actions/task_create_spec.rb b/spec/unit/actions/task_create_spec.rb index aa6e4542aa6..e6c46ac8265 100644 --- a/spec/unit/actions/task_create_spec.rb +++ b/spec/unit/actions/task_create_spec.rb @@ -72,6 +72,14 @@ module VCAP::CloudController expect(TaskModel.count).to eq(1) end + it 'snapshots the app account on task creation so later unbind cannot change task identity' do + account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') + app.update(service_account: account) + task = task_create_action.create(app, message, user_audit_info) + app.update(service_account: nil) + expect(task.reload.service_account_guid).to eq(account.guid) + end + it "sets the task state to 'RUNNING'" do task = task_create_action.create(app, message, user_audit_info) From 6c56629c2b2855413c244f416179fd11384a49d1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:36:44 +0200 Subject: [PATCH 28/57] Snapshot task account identity before asynchronous submission --- app/actions/task_create.rb | 2 ++ app/models/runtime/task_model.rb | 4 ++++ .../20261005120300_add_task_service_account_snapshot.rb | 8 ++++++++ lib/cloud_controller/diego/service_account_identity.rb | 7 ++++--- lib/cloud_controller/diego/task_environment.rb | 3 ++- lib/cloud_controller/diego/task_recipe_builder.rb | 2 +- 6 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 db/migrations/20261005120300_add_task_service_account_snapshot.rb diff --git a/app/actions/task_create.rb b/app/actions/task_create.rb index 2eddace7bad..36a7fc49cac 100644 --- a/app/actions/task_create.rb +++ b/app/actions/task_create.rb @@ -24,6 +24,8 @@ def create(app, message, user_audit_info, droplet: nil) task = TaskModel.create( name: use_requested_name_or_generate_name(message), app: app, + service_account_guid: app.service_account_guid, + service_account_snapshot: true, state: TaskModel::PENDING_STATE, droplet: droplet, command: command(message, template_process), diff --git a/app/models/runtime/task_model.rb b/app/models/runtime/task_model.rb index bad22c00eef..22ab05534aa 100644 --- a/app/models/runtime/task_model.rb +++ b/app/models/runtime/task_model.rb @@ -28,6 +28,10 @@ class TaskModel < Sequel::Model(:tasks) set_field_as_encrypted :environment_variables, column: :encrypted_environment_variables serializes_via_json :environment_variables + def runtime_service_account_guid + service_account_snapshot ? service_account_guid : app.service_account_guid + end + def after_update super diff --git a/db/migrations/20261005120300_add_task_service_account_snapshot.rb b/db/migrations/20261005120300_add_task_service_account_snapshot.rb new file mode 100644 index 00000000000..cdcdf66ba30 --- /dev/null +++ b/db/migrations/20261005120300_add_task_service_account_snapshot.rb @@ -0,0 +1,8 @@ +Sequel.migration do + change do + alter_table(:tasks) do + add_column :service_account_guid, String, size: 255 + add_column :service_account_snapshot, TrueClass, null: false, default: false + end + end +end diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb index 6ffd47ddfaf..401596ee921 100644 --- a/lib/cloud_controller/diego/service_account_identity.rb +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -1,9 +1,10 @@ module VCAP::CloudController module Diego class ServiceAccountIdentity - def initialize(app, config) + def initialize(app, config, account_guid: app.service_account_guid) @app = app @config = config + @account_guid = account_guid end def certificate_properties @@ -31,13 +32,13 @@ def environment private def ready_account - return unless @app.service_account_guid + return unless @account_guid unless @config.get(:service_account_runtime_enabled) == true raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account runtime is not enabled') end - account = ServiceAccountModel.first(guid: @app.service_account_guid) + account = ServiceAccountModel.first(guid: @account_guid) unless account && account.enabled && account.status == 'ready' && account.space_guid == @app.space_guid raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account is not ready for runtime credentials') end diff --git a/lib/cloud_controller/diego/task_environment.rb b/lib/cloud_controller/diego/task_environment.rb index c298015bd84..918147e5a29 100644 --- a/lib/cloud_controller/diego/task_environment.rb +++ b/lib/cloud_controller/diego/task_environment.rb @@ -23,7 +23,8 @@ def build merge(SystemEnvPresenter.new(app).system_env.stringify_keys) task_env = task_env.except('VCAP_SERVICE_ACCOUNT', :VCAP_SERVICE_ACCOUNT). - merge(ServiceAccountIdentity.new(app, Config.config).environment) + merge(ServiceAccountIdentity.new(app, Config.config, account_guid: task.service_account_snapshot ? task.service_account_guid : app.service_account_guid). + environment) task_env = task_env.merge('VCAP_PLATFORM_OPTIONS' => credhub_url) if credhub_url.present? && cred_interpolation_enabled? diff --git a/lib/cloud_controller/diego/task_recipe_builder.rb b/lib/cloud_controller/diego/task_recipe_builder.rb index d541d2012cd..f5b92869752 100644 --- a/lib/cloud_controller/diego/task_recipe_builder.rb +++ b/lib/cloud_controller/diego/task_recipe_builder.rb @@ -46,7 +46,7 @@ def build_app_task(config, task) root_fs: task_action_builder.stack, environment_variables: task_action_builder.task_environment_variables, placement_tags: [VCAP::CloudController::IsolationSegmentSelector.for_space(task.space)].compact, - certificate_properties: ServiceAccountIdentity.new(task.app, config).certificate_properties, + certificate_properties: ServiceAccountIdentity.new(task.app, config, account_guid: task.runtime_service_account_guid).certificate_properties, image_username: task.droplet.docker_receipt_username, image_password: task.droplet.docker_receipt_password, volume_mounted_files: ServiceBindingFilesBuilder.build(task.app) From 820fc8708c9db3f8b54bb3b46edd5c17f646623c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:38:16 +0200 Subject: [PATCH 29/57] Test process account launch snapshot on restart (red) --- spec/unit/actions/process_restart_spec.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spec/unit/actions/process_restart_spec.rb b/spec/unit/actions/process_restart_spec.rb index 2f659ed34ee..d57bec0f108 100644 --- a/spec/unit/actions/process_restart_spec.rb +++ b/spec/unit/actions/process_restart_spec.rb @@ -49,6 +49,17 @@ module VCAP::CloudController end context 'when the process is STARTED' do + it 'captures account identity only at restart and preserves it after delayed assignment changes' do + account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') + app.update(service_account: account) + ProcessRestart.restart(process: process, config: config, stop_in_runtime: true) + expect(process.reload.runtime_service_account_guid).to eq(account.guid) + app.update(service_account: nil) + expect(process.reload.runtime_service_account_guid).to eq(account.guid) + ProcessRestart.restart(process: process, config: config, stop_in_runtime: true) + expect(process.reload.runtime_service_account_guid).to be_nil + end + it 'keeps process state as STARTED' do ProcessRestart.restart(process: process, config: config, stop_in_runtime: true) expect(process.reload.state).to eq('STARTED') From 617239398b3280b094760ae2fd86815af5643882 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:40:39 +0200 Subject: [PATCH 30/57] Preserve process account launch identity until explicit restart --- app/actions/process_restart.rb | 2 +- app/models/runtime/process_model.rb | 8 ++++++++ ...20261005120400_add_process_service_account_snapshot.rb | 8 ++++++++ lib/cloud_controller/diego/app_recipe_builder.rb | 2 +- lib/cloud_controller/diego/environment.rb | 2 +- 5 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 db/migrations/20261005120400_add_process_service_account_snapshot.rb diff --git a/app/actions/process_restart.rb b/app/actions/process_restart.rb index 6a405e2647a..ba2f213bc07 100644 --- a/app/actions/process_restart.rb +++ b/app/actions/process_restart.rb @@ -14,7 +14,7 @@ def restart(process:, config:, stop_in_runtime:, revision: nil) runners(config).runner_for_process(process).stop end - process.update(state: ProcessModel::STARTED, revision: revision_to_set) + process.update(state: ProcessModel::STARTED, revision: revision_to_set, service_account_guid: process.app.service_account_guid, service_account_snapshot: true) runners(config).runner_for_process(process).start end end diff --git a/app/models/runtime/process_model.rb b/app/models/runtime/process_model.rb index eb031eda821..4038430d8eb 100644 --- a/app/models/runtime/process_model.rb +++ b/app/models/runtime/process_model.rb @@ -304,10 +304,18 @@ def before_validation end def before_save + if being_started? + self.service_account_guid = app.service_account_guid + self.service_account_snapshot = true + end set_new_version if version_needs_to_be_updated? super end + def runtime_service_account_guid + service_account_snapshot ? service_account_guid : app.service_account_guid + end + # rubocop:disable Metrics/CyclomaticComplexity def version_needs_to_be_updated? # change version if: diff --git a/db/migrations/20261005120400_add_process_service_account_snapshot.rb b/db/migrations/20261005120400_add_process_service_account_snapshot.rb new file mode 100644 index 00000000000..65f3a6c124d --- /dev/null +++ b/db/migrations/20261005120400_add_process_service_account_snapshot.rb @@ -0,0 +1,8 @@ +Sequel.migration do + change do + alter_table(:processes) do + add_column :service_account_guid, String, size: 255 + add_column :service_account_snapshot, TrueClass, null: false, default: false + end + end +end diff --git a/lib/cloud_controller/diego/app_recipe_builder.rb b/lib/cloud_controller/diego/app_recipe_builder.rb index f9513e43b00..7688a2ae7df 100644 --- a/lib/cloud_controller/diego/app_recipe_builder.rb +++ b/lib/cloud_controller/diego/app_recipe_builder.rb @@ -99,7 +99,7 @@ def app_lrp_arguments check_definition: generate_healthcheck_definition(desired_lrp_builder), routes: ::Diego::Bbs::Models::ProtoRoutes.new(routes:), max_pids: @config.get(:diego, :pid_limit), - certificate_properties: ServiceAccountIdentity.new(process.app, config).certificate_properties, + certificate_properties: ServiceAccountIdentity.new(process.app, config, account_guid: process.runtime_service_account_guid).certificate_properties, image_username: process.desired_droplet.docker_receipt_username, image_password: process.desired_droplet.docker_receipt_password, volume_mounted_files: ServiceBindingFilesBuilder.build(process) diff --git a/lib/cloud_controller/diego/environment.rb b/lib/cloud_controller/diego/environment.rb index fe7a04fe18d..d30ff19a5af 100644 --- a/lib/cloud_controller/diego/environment.rb +++ b/lib/cloud_controller/diego/environment.rb @@ -45,7 +45,7 @@ def common_json_and_merge(&blk) merge(SystemEnvPresenter.new(process).system_env) diego_env = diego_env.except('VCAP_SERVICE_ACCOUNT', :VCAP_SERVICE_ACCOUNT). - merge(ServiceAccountIdentity.new(process.app, Config.config).environment) + merge(ServiceAccountIdentity.new(process.app, Config.config, account_guid: process.runtime_service_account_guid).environment) diego_env = diego_env.merge(DATABASE_URL: process.database_uri) if process.database_uri From bbef6e4b77ae1828e79e8c4baa1dbccb09f2e295 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:46:58 +0200 Subject: [PATCH 31/57] Test staging exclusion of injected account discovery (red) --- .../backends/staging_environment_builder_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/unit/lib/cloud_controller/backends/staging_environment_builder_spec.rb b/spec/unit/lib/cloud_controller/backends/staging_environment_builder_spec.rb index 421d6867c6f..34183d6ed06 100644 --- a/spec/unit/lib/cloud_controller/backends/staging_environment_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/backends/staging_environment_builder_spec.rb @@ -60,6 +60,13 @@ module VCAP::CloudController }) end + it 'never exposes account discovery in staging even when supplied by a staging request' do + account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') + app.update(service_account: account) + result = builder.build(app, space, lifecycle, memory_limit, staging_disk_in_mb, { 'VCAP_SERVICE_ACCOUNT' => 'injected' }) + expect(result).not_to have_key('VCAP_SERVICE_ACCOUNT') + end + context 'when the app has a route associated with it' do it 'includes the uris as part of vcap_application' do route1 = create(:route, space:) From 87b3b82676fd54303c08f3ef6b3f53d0817e23f5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:48:19 +0200 Subject: [PATCH 32/57] Exclude service account discovery from all staging inputs --- lib/cloud_controller/backends/staging_environment_builder.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cloud_controller/backends/staging_environment_builder.rb b/lib/cloud_controller/backends/staging_environment_builder.rb index 9395c5d3c5c..e22c393d790 100644 --- a/lib/cloud_controller/backends/staging_environment_builder.rb +++ b/lib/cloud_controller/backends/staging_environment_builder.rb @@ -27,7 +27,8 @@ def build(app, space, lifecycle, memory_limit, staging_disk_in_mb, vars_from_mes 'MEMORY_LIMIT' => "#{memory_limit}m" } ). - merge(SystemEnvPresenter.new(app).system_env.stringify_keys) + merge(SystemEnvPresenter.new(app).system_env.stringify_keys). + except('VCAP_SERVICE_ACCOUNT', :VCAP_SERVICE_ACCOUNT) end end end From c40ff322455000b557418ead6ac3748ff8dc4d46 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:49:34 +0200 Subject: [PATCH 33/57] Test active identity deletion and secretless discovery guards (red) --- spec/request/service_accounts_spec.rb | 16 ++++++++++++++++ .../diego/service_account_identity_spec.rb | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 2dcc005cf71..c29e7afa507 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -230,5 +230,21 @@ def headers(role) expect(clients).not_to have_received(:delete) expect(account.reload.status).to eq('failed') end + + it 'rejects deletion while an unbound app still has the account in a running launch snapshot' do + app = create(:app_model, space: space) + create(:process_model, app: app, state: 'STARTED', service_account_guid: account.guid, service_account_snapshot: true) + delete account_path, nil, headers('space_manager') + expect(last_response.status).to eq(409) + expect(Delayed::Job.count).to eq(0) + end + + it 'rejects deletion while an active task holds the account snapshot' do + app = create(:app_model, space: space) + create(:task_model, app: app, service_account_guid: account.guid, service_account_snapshot: true, state: 'RUNNING') + delete account_path, nil, headers('space_manager') + expect(last_response.status).to eq(409) + expect(Delayed::Job.count).to eq(0) + end end end diff --git a/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb b/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb index bf26ab4f111..205a3af30ae 100644 --- a/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/service_account_identity_spec.rb @@ -49,5 +49,10 @@ module VCAP::CloudController::Diego expect(identity.certificate_properties.to_h).not_to have_key(:service_account) expect(identity.environment).to eq({}) end + + it 'rejects token discovery endpoints containing embedded credentials' do + TestConfig.config[:service_account_token_endpoint] = 'https://user:secret@uaa.example.test/oauth/token/mtls' + expect { identity.environment }.to raise_error(CloudController::Errors::ApiError, /endpoint/) + end end end From 5b59bd4ef58ef896b2840ceeb014db5ace6c5d27 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:52:25 +0200 Subject: [PATCH 34/57] Protect active launch identities and non-secret endpoint discovery --- app/actions/service_account_provision.rb | 2 +- app/controllers/v3/service_accounts_controller.rb | 2 +- app/models/runtime/service_account_model.rb | 6 ++++++ .../diego/service_account_identity.rb | 15 ++++++++++++--- spec/request/service_accounts_spec.rb | 3 ++- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index 07e42d76522..f5c1f7e6a17 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -44,7 +44,7 @@ def deprovision(account, delete: false) account.db.transaction(savepoint: true) do account.lock! raise Conflict.new('service account was enabled again') if account.enabled - raise Conflict.new('service account is still assigned') if delete && account.apps_dataset.any? + raise Conflict.new('service account is still assigned or in use') if delete && account.in_use? begin account.db.transaction(savepoint: true) do diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index fce64ce4450..51cf2820b26 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -80,7 +80,7 @@ def destroy job = nil account.db.transaction do account.lock! - lifecycle_conflict!('service account is still assigned') if account.apps_dataset.any? + lifecycle_conflict!('service account is still assigned or in use') if account.in_use? reject_active_operation!(account) account.update(enabled: false, status: 'deleting') job = enqueue_lifecycle(account, 'delete') diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 2b13c84920d..59054afd2d2 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -32,6 +32,12 @@ def client_id "cf:service-account:#{name}" end + def in_use? + apps_dataset.any? || + ProcessModel.where(service_account_guid: guid, state: ProcessModel::STARTED).any? || + TaskModel.where(service_account_guid: guid, state: [TaskModel::PENDING_STATE, TaskModel::RUNNING_STATE, TaskModel::CANCELING_STATE]).any? + end + def certificate_dns_san "#{name}.svc.identity" end diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb index 401596ee921..324584f52a4 100644 --- a/lib/cloud_controller/diego/service_account_identity.rb +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -1,3 +1,5 @@ +require 'uri' + module VCAP::CloudController module Diego class ServiceAccountIdentity @@ -19,9 +21,7 @@ def environment return {} unless account endpoint = @config.get(:service_account_token_endpoint) - unless endpoint.is_a?(String) && endpoint.start_with?('https://') - raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account token endpoint is not configured') - end + raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account token endpoint is not configured') unless valid_endpoint?(endpoint) { 'VCAP_SERVICE_ACCOUNT' => { guid: account.guid, name: account.name, client_id: account.client_id, @@ -31,6 +31,15 @@ def environment private + def valid_endpoint?(endpoint) + return false unless endpoint.is_a?(String) + + uri = URI.parse(endpoint) + uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil? + rescue URI::InvalidURIError + false + end + def ready_account return unless @account_guid diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index c29e7afa507..cd58011f0c8 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -233,7 +233,8 @@ def headers(role) it 'rejects deletion while an unbound app still has the account in a running launch snapshot' do app = create(:app_model, space: space) - create(:process_model, app: app, state: 'STARTED', service_account_guid: account.guid, service_account_snapshot: true) + process = create(:process_model, app: app, state: 'STARTED') + process.update(service_account_guid: account.guid, service_account_snapshot: true) delete account_path, nil, headers('space_manager') expect(last_response.status).to eq(409) expect(Delayed::Job.count).to eq(0) From e4e4368135bb0004054829d54ce2f821be1178d6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:56:15 +0200 Subject: [PATCH 35/57] Verify pollable account provisioning retries through completion --- spec/request/app_service_accounts_spec.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index 07db04402c9..307e5607ba8 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -153,12 +153,27 @@ def headers(role) end it 'rolls back desired binding and state if enqueueing fails' do - allow_any_instance_of(VCAP::CloudController::Jobs::Enqueuer).to receive(:enqueue_pollable).and_raise('queue unavailable') + error = CloudController::Errors::ApiError.new_from_details('ServerError') + allow_any_instance_of(VCAP::CloudController::Jobs::Enqueuer).to receive(:enqueue_pollable).and_raise(error) patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') expect(last_response.status).to eq(500) expect(app_model.reload.service_account_guid).to be_nil expect(account.reload.status).to eq('reserved') end + + it 'retries a failed bind through the same pollable job and then reports completion' do + allow(clients).to receive(:add).and_raise(CF::UAA::BadTarget, 'unavailable') + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(202) + location = last_response.headers['Location'] + expect(Delayed::Worker.new.work_off).to eq([0, 1]) + expect(account.reload.status).to eq('failed') + allow(clients).to receive(:add) + Delayed::Job.first.update(run_at: Time.now.utc - 1) + expect(Delayed::Worker.new.work_off).to eq([1, 0]) + get URI(location).path, nil, headers('space_developer') + expect(Oj.load(last_response.body)['state']).to eq('COMPLETE') + end end [{}, { data: {} }, { data: { guid: nil } }, { data: { guid: 'x', name: 'injected' } }, { data: [] }].each do |body| From 30a72684c6523bfc19fc7e20f86b50588b96aeaf Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:59:01 +0200 Subject: [PATCH 36/57] Verify concurrent apps share one first-bind provisioning job --- .../app_assign_service_account_spec.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/unit/actions/app_assign_service_account_spec.rb b/spec/unit/actions/app_assign_service_account_spec.rb index 8905ed7e29e..e2e91617322 100644 --- a/spec/unit/actions/app_assign_service_account_spec.rb +++ b/spec/unit/actions/app_assign_service_account_spec.rb @@ -46,5 +46,24 @@ module VCAP::CloudController account.update(enabled: true, status: 'reserved') expect { action.assign(app, account) }.to raise_error(AppAssignServiceAccount::Conflict, /not ready/) end + + it 'serializes concurrent first binds to two apps into a single provisioning job', isolation: :truncation do + account.update(status: 'reserved') + other_app = create(:app_model, space: space) + ids = [app.guid, other_app.guid] + writer = create(:user) + space.organization.add_user(writer) + space.add_developer(writer) + permission_queryer = Permissions.new(writer) + threads = ids.map do |guid| + Thread.new do + AppAssignServiceAccount.new(permission_queryer).assign(AppModel.first(guid: guid), ServiceAccountModel.first(guid: account.guid), provision: true) + end + end + jobs = threads.map(&:value) + expect(jobs.map(&:guid).uniq.size).to eq(1) + expect(PollableJobModel.where(resource_guid: account.guid).count).to eq(1) + expect(AppModel.where(guid: ids).select_map(:service_account_guid)).to eq([account.guid, account.guid]) + end end end From 7435e1082aa52286c31ad2711e44d9852b0736a9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:00:16 +0200 Subject: [PATCH 37/57] Test provisioning validation for API local workers (red) --- .../service_account_provisioner_configuration_spec.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb b/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb index 82fb2211ec5..1114b96d1c3 100644 --- a/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb +++ b/spec/unit/lib/cloud_controller/service_account_provisioner_configuration_spec.rb @@ -19,6 +19,11 @@ module VCAP::CloudController expect { ConfigSchemas::WorkerSchema.validate(worker_config) }.not_to raise_error end + it 'validates provisioning configuration for API-hosted local workers too' do + api_config = Config.read_file('config/cloud_controller.yml').merge(service_account_provisioning: settings.except(:identity_ca)) + expect { ConfigSchemas::ApiSchema.validate(api_config) }.to raise_error(Membrane::SchemaValidationError, /identity_ca => Missing key/) + end + %i[client_id client_secret identity_ca].each do |key| it "rejects worker configuration missing #{key}" do worker_config[:service_account_provisioning] = settings.except(key) From dc3d3a00c3795d379b60fa497c45843888e856a7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:01:38 +0200 Subject: [PATCH 38/57] Validate optional provisioning inputs for API local workers --- lib/cloud_controller/config_schemas/api_schema.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index bf3fb0630a0..560354cc83e 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -33,6 +33,11 @@ class ApiSchema < VCAP::Config optional(:service_account_provisioning_enabled) => bool, optional(:service_account_runtime_enabled) => bool, optional(:service_account_token_endpoint) => String, + optional(:service_account_provisioning) => { + client_id: String, + client_secret: String, + identity_ca: String + }, system_domain: String, optional(:system_domain_organization) => enum(String, NilClass), From b74c29494588fc6f6b4b5bb2174bd0d2d18d3925 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:03:06 +0200 Subject: [PATCH 39/57] Test pre-feature launches cannot acquire new account identity (red) --- spec/unit/actions/process_restart_spec.rb | 7 +++++++ spec/unit/lib/cloud_controller/diego/environment_spec.rb | 1 + .../lib/cloud_controller/diego/task_environment_spec.rb | 1 + .../lib/cloud_controller/diego/task_recipe_builder_spec.rb | 1 + 4 files changed, 10 insertions(+) diff --git a/spec/unit/actions/process_restart_spec.rb b/spec/unit/actions/process_restart_spec.rb index d57bec0f108..dc22232bb7f 100644 --- a/spec/unit/actions/process_restart_spec.rb +++ b/spec/unit/actions/process_restart_spec.rb @@ -49,6 +49,13 @@ module VCAP::CloudController end context 'when the process is STARTED' do + it 'does not grant a newly bound account to a pre-feature launch before restart' do + account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') + app.update(service_account: account) + process.this.update(service_account_snapshot: false, service_account_guid: nil) + expect(process.reload.runtime_service_account_guid).to be_nil + end + it 'captures account identity only at restart and preserves it after delayed assignment changes' do account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') app.update(service_account: account) diff --git a/spec/unit/lib/cloud_controller/diego/environment_spec.rb b/spec/unit/lib/cloud_controller/diego/environment_spec.rb index bcb039d6fc7..fb2e350f8c2 100644 --- a/spec/unit/lib/cloud_controller/diego/environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/environment_spec.rb @@ -43,6 +43,7 @@ module VCAP::CloudController::Diego TestConfig.override(service_account_runtime_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: process.space, status: 'ready') process.app.update(service_account: account) + process.update(service_account_guid: account.guid, service_account_snapshot: true) environment['VCAP_SERVICE_ACCOUNT'] = 'injected' value = Environment.new(process).as_json.find { |entry| entry['name'] == 'VCAP_SERVICE_ACCOUNT' }['value'] expect(Oj.load(value)).to include('name' => account.name, 'client_id' => account.client_id) diff --git a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb index a2221a06df4..e7a7c83a649 100644 --- a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb @@ -46,6 +46,7 @@ module VCAP::CloudController::Diego TestConfig.override(service_account_runtime_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') account = VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') app.update(service_account: account) + task.update(service_account_guid: account.guid, service_account_snapshot: true) env = TaskEnvironment.new(app, task, space, { 'VCAP_SERVICE_ACCOUNT' => 'injected' }).build expect(env['VCAP_SERVICE_ACCOUNT']).to include(name: account.name, client_id: account.client_id) end diff --git a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb index 11c7a442932..5020b0f17cb 100644 --- a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb @@ -528,6 +528,7 @@ module Diego config.config_hash[:service_account_runtime_enabled] = true account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') app.update(service_account: account) + task.update(service_account_guid: account.guid, service_account_snapshot: true) expect(task_recipe_builder.build_app_task(config, task).certificate_properties.service_account.name).to eq(account.name) end From 7c7c97b72a130d6a02c722d0e245426e951adfd4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:04:50 +0200 Subject: [PATCH 40/57] Keep pre-feature launches account-free until explicit launch --- app/models/runtime/process_model.rb | 2 +- app/models/runtime/task_model.rb | 2 +- lib/cloud_controller/diego/task_environment.rb | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/models/runtime/process_model.rb b/app/models/runtime/process_model.rb index 4038430d8eb..d376ba07695 100644 --- a/app/models/runtime/process_model.rb +++ b/app/models/runtime/process_model.rb @@ -313,7 +313,7 @@ def before_save end def runtime_service_account_guid - service_account_snapshot ? service_account_guid : app.service_account_guid + service_account_snapshot ? service_account_guid : nil end # rubocop:disable Metrics/CyclomaticComplexity diff --git a/app/models/runtime/task_model.rb b/app/models/runtime/task_model.rb index 22ab05534aa..7cf82b686bc 100644 --- a/app/models/runtime/task_model.rb +++ b/app/models/runtime/task_model.rb @@ -29,7 +29,7 @@ class TaskModel < Sequel::Model(:tasks) serializes_via_json :environment_variables def runtime_service_account_guid - service_account_snapshot ? service_account_guid : app.service_account_guid + service_account_snapshot ? service_account_guid : nil end def after_update diff --git a/lib/cloud_controller/diego/task_environment.rb b/lib/cloud_controller/diego/task_environment.rb index 918147e5a29..129f7ec0055 100644 --- a/lib/cloud_controller/diego/task_environment.rb +++ b/lib/cloud_controller/diego/task_environment.rb @@ -23,8 +23,7 @@ def build merge(SystemEnvPresenter.new(app).system_env.stringify_keys) task_env = task_env.except('VCAP_SERVICE_ACCOUNT', :VCAP_SERVICE_ACCOUNT). - merge(ServiceAccountIdentity.new(app, Config.config, account_guid: task.service_account_snapshot ? task.service_account_guid : app.service_account_guid). - environment) + merge(ServiceAccountIdentity.new(app, Config.config, account_guid: task.runtime_service_account_guid).environment) task_env = task_env.merge('VCAP_PLATFORM_OPTIONS' => credhub_url) if credhub_url.present? && cred_interpolation_enabled? From b9fc5c142775a10d2c412883f9346a1b83bf66fb Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:08:21 +0200 Subject: [PATCH 41/57] Test account app listing applies requested filters (red) --- spec/request/service_accounts_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index cd58011f0c8..700a59a8fe4 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -126,6 +126,8 @@ def headers(role) get "/v3/service_accounts/#{own.guid}/apps", nil, headers('space_auditor') expect(last_response.status).to eq(200) expect(Oj.load(last_response.body)['resources'].pluck('guid')).to eq([app.guid]) + get "/v3/service_accounts/#{own.guid}/apps?names=missing-app", nil, headers('space_auditor') + expect(Oj.load(last_response.body)['resources']).to eq([]) end it 'denies developers updates and hides unreadable resources' do From b8d818788f5f9ece21d2ebd65b1ceb36304f75e6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:09:44 +0200 Subject: [PATCH 42/57] Apply normal app filters to account app listing --- app/controllers/v3/service_accounts_controller.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 51cf2820b26..f2a02b7dce2 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -3,6 +3,7 @@ require 'messages/service_account_update_message' require 'messages/service_accounts_list_message' require 'messages/apps_list_message' +require 'fetchers/app_list_fetcher' require 'presenters/v3/app_presenter' require 'repositories/service_account_event_repository' @@ -21,7 +22,8 @@ def apps account = readable_account message = AppsListMessage.from_params(query_params) invalid_param!(message.errors.full_messages) unless message.valid? - render_list(account.apps_dataset, message, Presenters::V3::AppPresenter, "/v3/service_accounts/#{account.guid}/apps") + dataset = AppListFetcher.fetch(message, [account.space_guid]).where(service_account_guid: account.guid) + render_list(dataset, message, Presenters::V3::AppPresenter, "/v3/service_accounts/#{account.guid}/apps") end def show From 468201e543e6b53c9be3c93ef0b59a9089f571bf Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:11:08 +0200 Subject: [PATCH 43/57] Test bind exclusion during retrying lifecycle operations (red) --- spec/request/app_service_accounts_spec.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index 307e5607ba8..0f9ce6fdd61 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -152,6 +152,17 @@ def headers(role) expect(Delayed::Job.count).to eq(0) end + it 'does not bind an account while a delete operation is retrying' do + account.update(status: 'failed') + VCAP::CloudController::PollableJobModel.create( + delayed_job_guid: SecureRandom.uuid, operation: 'service_account.delete', + resource_guid: account.guid, resource_type: 'service_account', state: 'PROCESSING' + ) + patch path, { data: { guid: account.guid } }.to_json, headers('space_developer') + expect(last_response.status).to eq(409) + expect(app_model.reload.service_account_guid).to be_nil + end + it 'rolls back desired binding and state if enqueueing fails' do error = CloudController::Errors::ApiError.new_from_details('ServerError') allow_any_instance_of(VCAP::CloudController::Jobs::Enqueuer).to receive(:enqueue_pollable).and_raise(error) From da5b96bcdbb65d2b0ec74dea010f44c74f7c8995 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:12:32 +0200 Subject: [PATCH 44/57] Block binding during retrying disable or delete operations --- app/actions/app_assign_service_account.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 8b8d69bf361..5a848b47d2b 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -47,6 +47,10 @@ def record_assignment(app, account, previous_guid) def validate_ready!(account, provision) raise Conflict.new('service account is not ready or enabled') unless account.enabled && (account.status == 'ready' || provision) + + if PollableJobModel.where(resource_guid: account.guid, resource_type: 'service_account', state: %w[PROCESSING POLLING]).exclude(operation: 'service_account.provision').any? + raise Conflict.new('service account lifecycle operation is in progress') + end end def provision_account(account) From 2c9e34c918777318b9dbf5d03bd0a8ef80106e24 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:13:46 +0200 Subject: [PATCH 45/57] Test owned account guard before recursive space deletion (red) --- spec/unit/actions/space_delete_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index f09e6c59980..a11c84c0536 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -27,6 +27,15 @@ module VCAP::CloudController expect { space.refresh }.to raise_error Sequel::Error, 'Record not found' end + it 'reports owned accounts before deleting apps or other space resources' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space) + errors = space_delete.delete([space]) + expect(errors.map(&:message).join).to include('service accounts') + expect(AppModel.first(guid: app.guid)).not_to be_nil + expect(ServiceAccountModel.first(guid: account.guid)).not_to be_nil + expect(Space.first(guid: space.guid)).not_to be_nil + end + it 'creates audit events for recursive app deletion and space deletion' do space_delete.delete([space]) expect(VCAP::CloudController::Event.count).to eq(2) From a19c2924a19ced6630e5638adc1b0d0510c6e388 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:15:08 +0200 Subject: [PATCH 46/57] Report owned service accounts before recursive space deletion --- app/actions/space_delete.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/actions/space_delete.rb b/app/actions/space_delete.rb index 6d73dd46b4c..19111a769f1 100644 --- a/app/actions/space_delete.rb +++ b/app/actions/space_delete.rb @@ -10,6 +10,11 @@ def initialize(user_audit_info, services_event_repository) def delete(dataset) dataset.each_with_object([]) do |space_model, errors| + if ServiceAccountModel.where(space_guid: space_model.guid).any? + errors << CloudController::Errors::ApiError.new_from_details('SpaceDeletionFailed', space_model.name, 'Delete owned service accounts before deleting the space.') + next + end + instance_delete_errors = delete_service_instances(space_model) err = accumulate_space_deletion_error(instance_delete_errors, space_model.name) errors << err unless err.nil? From aab9c77f1296012dc54bf9e91fbcf055790d031a Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 22:33:52 +0200 Subject: [PATCH 47/57] Verify rebuilt LRPs preserve launch account after unbind --- .../diego/app_recipe_builder_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb index e75ebf58b10..14a19509452 100644 --- a/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb @@ -356,6 +356,20 @@ module Diego it_behaves_like 'creating a desired lrp' + context 'with a launched service account' do + let(:config) do + Config.new({ service_account_runtime_enabled: true, + diego: { use_privileged_containers_for_running: false, lifecycle_bundles: { 'potato-stack' => 'some-uri' }, pid_limit: 100 } }) + end + + it 'keeps the launch identity in rebuilt LRPs after desired unbind' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space, status: 'ready') + process.update(service_account_guid: account.guid, service_account_snapshot: true) + app_model.update(service_account: nil) + expect(builder.build_app_lrp.certificate_properties.service_account.name).to eq(account.name) + end + end + it 'creates a desired lrp with buildpack specific properties' do lrp = builder.build_app_lrp expect(lrp.environment_variables).to contain_exactly(::Diego::Bbs::Models::EnvironmentVariable.new(name: 'foo', value: 'bar')) From 8af06c44022e64b1b5b4e081234c807f2747a6a9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 22:39:16 +0200 Subject: [PATCH 48/57] Document experimental space-owned service account API and rollout --- .../api_resources/_service_accounts.md | 137 ++++++++++++++++++ docs/v3/source/index.html.md | 1 + 2 files changed, 138 insertions(+) create mode 100644 docs/v3/source/includes/api_resources/_service_accounts.md diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md new file mode 100644 index 00000000000..2268c4970cf --- /dev/null +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -0,0 +1,137 @@ +# Service accounts (experimental) + +Service accounts are immutable, space-owned identities shared by apps in that +space. Each app has zero or one desired account. Names are foundation-unique +lowercase DNS labels of 3–63 characters and remain reserved after deletion. +Cross-space assignment is forbidden, including within the same organization. + +## Resource and permissions + +Account representations include `guid`, `name`, `description`, `metadata`, +`enabled`, `status`, `client_id`, `certificate_dns_san`, timestamps and the owning +`relationships.space`. Identity fields and owning space cannot be updated. +The client ID is `cf:service-account:` and DNS SAN is `.svc.identity`. + +Owning-space readers can show/list accounts and their apps. Space managers and +platform administrators manage accounts in writable spaces. App writers may assign +an enabled same-space account. Account management does not grant app-write access. +Account assignment does not grant any resource roles to the account principal. + +| Method | Path | Behavior | +| --- | --- | --- | +| POST | `/v3/service_accounts` | Reserve an account; returns 201, initially `reserved` | +| GET | `/v3/service_accounts` | Paginated readable accounts; `names`, `space_guids`, standard pagination/order parameters | +| GET | `/v3/service_accounts/:guid` | Show readable account | +| GET | `/v3/service_accounts/:guid/apps` | Paginated apps with desired assignment | +| PATCH | `/v3/service_accounts/:guid` | Update description/metadata (200) or enabled state (202) | +| DELETE | `/v3/service_accounts/:guid` | Queue unused-account deletion; returns 202 | +| GET | `/v3/apps/:guid/relationships/service_account` | Desired relationship `{ "data": null }` or account GUID | +| PATCH | `/v3/apps/:guid/relationships/service_account` | Assign GUID or explicitly unbind with `{ "data": null }` | + +Minimal creation body (permitted roles: owning-space manager or platform admin): + +```json +{ + "name": "payments-worker", + "relationships": { "space": { "data": { "guid": "SPACE_GUID" } } } +} +``` + +Optional creation fields are `description` and standard `metadata.labels` and +`metadata.annotations`. Metadata updates use the normal merge/removal semantics. + +Example account object: + +```json +{ + "guid": "ACCOUNT_GUID", + "name": "payments-worker", + "description": "", + "enabled": true, + "status": "reserved", + "client_id": "cf:service-account:payments-worker", + "certificate_dns_san": "payments-worker.svc.identity", + "created_at": "2026-10-05T12:00:00Z", + "updated_at": "2026-10-05T12:00:00Z", + "metadata": { "labels": {}, "annotations": {} }, + "relationships": { "space": { "data": { "guid": "SPACE_GUID" } } }, + "links": { + "self": { "href": "https://api.example.org/v3/service_accounts/ACCOUNT_GUID" } + } +} +``` + +Permitted roles for GET endpoints are owning-space readers and global readers. +PATCH/DELETE account endpoints require an owning-space manager or platform admin; +PATCH app relationship requires app-write permission in the owning space. + +Assign an account with `{ "data": { "guid": "ACCOUNT_GUID" } }` on the app +relationship endpoint. Clear it with `{ "data": null }`. PATCH an account with +`{ "enabled": false }` or `{ "enabled": true }` to disable or enable authentication. + +## Provisioning and lifecycle + +First authorized bind is asynchronous when operator provisioning is enabled. +It persists the desired assignment, marks the account `reconciling`, and returns +202 with a `/v3/jobs/:guid` Location. Poll until completion before starting or +restarting the app. Concurrent binds share the active provisioning job. A ready +account bind returns 200. Replacing an account requires explicit unbind first. +Bind/unbind return restart guidance and never restart the app automatically. + +Provisioning registers one canonical certificate-authenticated, secretless UAA +client and creates a roleless OAuth principal with GUID equal to the client ID. +Client collisions are rejected rather than overwritten/adopted. Transient worker +failures expose `failed` state and retry up to three attempts. A later authorized +bind can retry after the previous job has terminated. Unbind while provisioning +does not cause the worker to restore an assignment. + +Use `/v3/roles` with `relationships.user.data.guid` equal to the canonical client +ID. Explicit organization membership is required before granting space roles. +Roles belong to the shared account principal and are retained with zero apps or +while authentication is disabled. An account token cannot identify which app used it. + +PATCH `enabled: false` immediately prevents new assignment/launch identity and +queues UAA registration deletion. Enabling recreates the canonical registration +and preserves resource roles. The local UAA lifecycle strategy uses client deletion +because the selected UAA model has no enabled-client property. This must be verified +against the deployed UAA configuration before claiming token-issuance denial. + +Deleting requires no desired app assignments, started process snapshots or active +task snapshots. The worker rechecks usage and registration trust before removing +the client, principal and account. The name reservation is retained permanently. +Concurrent lifecycle operations return 409. All asynchronous operations use the +standard job polling endpoint. +Delete owned accounts explicitly before deleting their space. Recursive space +deletion reports an error before deleting that space's apps or service resources +when an account remains. + +## Runtime and rollout + +Runtime identity is enabled separately from provisioning. New process launches and +tasks capture the account at launch/creation. Later bind/unbind changes require +restart; scaling/redriving retains the captured identity. Pre-feature launches have +no account identity until explicit launch/restart. Staging excludes account identity +and discovery. Non-ready, disabled, missing or wrong-space accounts fail closed. + +Runtime `VCAP_SERVICE_ACCOUNT` contains account GUID/name, canonical client ID/SAN +and the configured HTTPS mTLS token endpoint, without secrets. Workloads use their +existing instance credentials; platform-derived typed certificate properties +preserve app/space/org organizational units. Account SANs are not route names. + +BOSH properties under `cc.service_accounts`: + +- `provisioning_enabled` (default false): enable API lifecycle/first-bind enqueueing + and dedicated worker provisioning configuration. +- `runtime_enabled` (default false): enable account identity only after compatible + BBS, rep and cell versions are present throughout the foundation. +- `token_endpoint`: HTTPS UAA mTLS endpoint without credentials/query/fragment. +- Worker-only `management_client_id`, `management_client_secret`, `identity_ca`: + dedicated UAA management credentials and the instance identity CA PEM, separate + from the UAA server TLS CA. Configure namespace protection independently in UAA. + +App-hosted local workers may receive equivalent `service_account_provisioning` +configuration separately; the API BOSH template does not render management secrets. +UAA audience/issuer, protected namespace, trusted proxy profile, leaf-expiry token +lifetime cap and the selected UAA `cnf` claim deviation require integration evidence. +Disabling/deleting clients, unbinding and restarting do not revoke already-issued +JWTs or certificates immediately. Certificate-only route grants are separate. diff --git a/docs/v3/source/index.html.md b/docs/v3/source/index.html.md index efb673f35ec..9af499384d7 100644 --- a/docs/v3/source/index.html.md +++ b/docs/v3/source/index.html.md @@ -29,6 +29,7 @@ includes: - api_resources/routes - api_resources/security_groups - api_resources/service_brokers + - api_resources/service_accounts - api_resources/service_offerings - api_resources/service_plans - api_resources/service_plan_visibility From 27549c8746b3e5b908992c02029730d15908fe4f Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 07:19:23 +0200 Subject: [PATCH 49/57] Test MySQL account relationship collation on migration retry (red) --- spec/unit/service_account_migration_spec.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 spec/unit/service_account_migration_spec.rb diff --git a/spec/unit/service_account_migration_spec.rb b/spec/unit/service_account_migration_spec.rb new file mode 100644 index 00000000000..5311d4aa6f8 --- /dev/null +++ b/spec/unit/service_account_migration_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +RSpec.describe 'service account app relationship migration' do + it 'matches MySQL parent collation when retrying a partially applied migration' do + db = Sequel.mock(host: :mysql, fetch: [{ Field: 'guid', Collation: 'utf8mb3_general_ci' }]) + allow(db).to receive(:schema).with(:apps).and_return([[:service_account_guid, { type: :string }]]) + migration = eval(File.read(File.expand_path('../../db/migrations/20261005120100_add_app_service_account.rb', __dir__))) + migration.apply(db, :up) + expect(db.sqls.join).to include('COLLATE utf8mb3_general_ci') + end +end From e4fdc2b67ee2b15a927cb9ea5f75a653f615191b Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 07:21:56 +0200 Subject: [PATCH 50/57] Match parent MySQL collation and retry partial account assignment migration --- .../20261005120100_add_app_service_account.rb | 12 +++++++++++- spec/unit/service_account_migration_spec.rb | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/db/migrations/20261005120100_add_app_service_account.rb b/db/migrations/20261005120100_add_app_service_account.rb index 508af30c99d..6bc61c54722 100644 --- a/db/migrations/20261005120100_add_app_service_account.rb +++ b/db/migrations/20261005120100_add_app_service_account.rb @@ -2,8 +2,18 @@ no_transaction up do + options = { size: 255 } + if database_type == :mysql + parent = fetch("SHOW FULL COLUMNS FROM service_accounts LIKE 'guid'").first + options[:collate] = parent[:Collation] || parent[:collation] + end + existing_column = schema(:apps).any? { |name, _| name == :service_account_guid } alter_table :apps do - add_column :service_account_guid, String, size: 255 + if existing_column + set_column_type :service_account_guid, String, **options, size: 255 + else + add_column :service_account_guid, String, **options, size: 255 + end add_foreign_key [:service_account_guid], :service_accounts, key: :guid, name: :fk_apps_service_account_guid end VCAP::Migration.with_concurrent_timeout(self) do diff --git a/spec/unit/service_account_migration_spec.rb b/spec/unit/service_account_migration_spec.rb index 5311d4aa6f8..d390c561a63 100644 --- a/spec/unit/service_account_migration_spec.rb +++ b/spec/unit/service_account_migration_spec.rb @@ -4,7 +4,8 @@ it 'matches MySQL parent collation when retrying a partially applied migration' do db = Sequel.mock(host: :mysql, fetch: [{ Field: 'guid', Collation: 'utf8mb3_general_ci' }]) allow(db).to receive(:schema).with(:apps).and_return([[:service_account_guid, { type: :string }]]) - migration = eval(File.read(File.expand_path('../../db/migrations/20261005120100_add_app_service_account.rb', __dir__))) + load File.expand_path('../../db/migrations/20261005120100_add_app_service_account.rb', __dir__) + migration = Sequel::Migration.descendants.last migration.apply(db, :up) expect(db.sqls.join).to include('COLLATE utf8mb3_general_ci') end From 4188ec61dbc9ad6a6f9c48f7d6cd38f6b73b73a9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 08:23:22 +0200 Subject: [PATCH 51/57] Test UAA normalized inert scope during account reconciliation (red) --- spec/unit/actions/service_account_provision_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index e6575cd6ae9..d016543e43f 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -39,6 +39,14 @@ module VCAP::CloudController expect(User.where(guid: account.client_id).count).to eq(1) end + it 'accepts UAA normalized inert scope during disable reconciliation' do + account.update(enabled: false) + allow(clients).to receive(:get).and_return(registration.merge('scope' => ['uaa.none'])) + expect(clients).to receive(:delete).with(:client, account.client_id) + action.deprovision(account) + expect(account.reload.status).to eq('disabled') + end + it 'does not adopt an existing unmanaged client even if its SAN matches' do allow(clients).to receive(:get).and_return(registration.except('cf_service_account_guid')) expect(clients).not_to receive(:add) From 8a4e504ae54b3b90753ebeedb0215a0c553c3ad1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 08:24:50 +0200 Subject: [PATCH 52/57] Accept UAA inert scope normalization for managed client reconciliation --- app/actions/service_account_provision.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index f5c1f7e6a17..47b6166743e 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -81,6 +81,7 @@ def matching_registration?(existing, desired) desired.all? do |key, value| actual = existing[key] actual = [] if key == 'scope' && !existing.key?(key) + actual = [] if key == 'scope' && actual == ['uaa.none'] if value.is_a?(Array) actual.is_a?(Array) && actual.all? { |entry| entry.is_a?(String) } && actual.sort == value.sort else From 93fffd2bc7191453a185fa8b7786933b1ff63c40 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:23:43 +0200 Subject: [PATCH 53/57] Test rolling service account creation budget and admin exemption (red) --- spec/request/service_accounts_spec.rb | 93 +++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 700a59a8fe4..78dc6e041b5 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -30,6 +30,99 @@ def headers(role) expect(last_response.status).to eq(201) end + context 'weekly creation budget' do + before { TestConfig.override(service_account_creation_limit: 1) } + + it 'limits a principal across spaces without refunding deleted accounts' do + auth = headers('space_manager') + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(201) + VCAP::CloudController::ServiceAccountModel.first.destroy + other_space = create(:space, organization: org) + other_space.add_manager(user) + other_body = { name: 'other-worker', relationships: { space: { data: { guid: other_space.guid } } } } + + post '/v3/service_accounts', other_body.to_json, auth + + expect(last_response.status).to eq(429) + expect(Oj.load(last_response.body).dig('errors', 0, 'title')).to eq('CF-ServiceAccountCreationLimitExceeded') + expect(VCAP::CloudController::ServiceAccountModel.count).to eq(0) + end + + it 'uses a rolling seven-day window' do + auth = headers('space_manager') + Timecop.freeze(Time.utc(2026, 10, 1)) do + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(201) + end + Timecop.freeze(Time.utc(2026, 10, 7, 23, 59, 59)) do + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, auth + expect(last_response.status).to eq(429) + end + Timecop.freeze(Time.utc(2026, 10, 8)) do + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, auth + expect(last_response.status).to eq(201) + end + end + + it 'does not consume budget on a failed creation' do + auth = headers('space_manager') + VCAP::CloudController::ServiceAccountModel.create(name: body[:name], space: space) + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(409) + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, auth + expect(last_response.status).to eq(201) + post '/v3/service_accounts', body.merge(name: 'third-worker').to_json, auth + expect(last_response.status).to eq(429) + end + + it 'does not share budget between principals' do + post '/v3/service_accounts', body.to_json, headers('space_manager') + expect(last_response.status).to eq(201) + other_user = create(:user) + auth = set_user_with_header_as_role(role: 'space_manager', org: org, space: space, user: other_user) + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, auth + expect(last_response.status).to eq(201) + end + + it 'supports explicit unlimited configuration' do + TestConfig.override(service_account_creation_limit: -1) + auth = headers('space_manager') + %w[first-worker second-worker].each do |name| + post '/v3/service_accounts', body.merge(name: name).to_json, auth + expect(last_response.status).to eq(201) + end + end + + it 'exempts platform admins even when the configured budget is zero' do + TestConfig.override(service_account_creation_limit: 0) + auth = headers('admin') + %w[first-worker second-worker].each do |name| + post '/v3/service_accounts', body.merge(name: name).to_json, auth + expect(last_response.status).to eq(201) + end + post '/v3/service_accounts', body.to_json, headers('space_manager') + expect(last_response.status).to eq(429) + end + + it 'applies to non-admin automation clients and exempts admin automation' do + user.update(is_oauth_client: true) + org.add_user(user) + space.add_manager(user) + coder = CF::UAA::TokenCoder.new(audience_ids: TestConfig.config[:uaa][:resource_id], skey: TestConfig.config[:uaa][:symmetric_secret], pkey: nil) + token = { client_id: user.guid, scope: %w[cloud_controller.read cloud_controller.write], jti: 'automation', iss: UAAIssuer::ISSUER } + auth = base_json_headers('HTTP_AUTHORIZATION' => "bearer #{coder.encode(token)}") + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(201) + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, auth + expect(last_response.status).to eq(429) + token[:scope] << 'cloud_controller.admin' + admin_auth = base_json_headers('HTTP_AUTHORIZATION' => "bearer #{coder.encode(token)}") + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, admin_auth + expect(last_response.status).to eq(201) + end + end + it 'denies a space developer account-creation authority' do post '/v3/service_accounts', body.to_json, headers('space_developer') expect(last_response.status).to eq(403) From a1a6dc0b519dd2c9079ca8246b5b66141249750b Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:29:25 +0200 Subject: [PATCH 54/57] Enforce atomic weekly service account creation budget for non-admins --- .../service_account_creation_budget.rb | 25 +++++++++++++++ .../v3/service_accounts_controller.rb | 9 ++++-- ...120000_create_service_account_creations.rb | 10 ++++++ .../api_resources/_service_accounts.md | 9 ++++++ errors/v3.yml | 5 +++ .../config_schemas/api_schema.rb | 1 + .../service_account_creation_budget_spec.rb | 32 +++++++++++++++++++ 7 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 app/actions/service_account_creation_budget.rb create mode 100644 db/migrations/20261007120000_create_service_account_creations.rb create mode 100644 spec/unit/actions/service_account_creation_budget_spec.rb diff --git a/app/actions/service_account_creation_budget.rb b/app/actions/service_account_creation_budget.rb new file mode 100644 index 00000000000..48fd6ee9e68 --- /dev/null +++ b/app/actions/service_account_creation_budget.rb @@ -0,0 +1,25 @@ +module VCAP::CloudController + class ServiceAccountCreationBudget + WINDOW = 7.days + + def self.consume(principal, exempt:) + return yield if exempt + + # Lock the authenticated principal, not a space: requests on different API + # instances and in different spaces must share one atomic budget. + principal.lock! + now = Time.now.utc + creations = ServiceAccountModel.db[:service_account_creations] + limit = Config.config.get(:service_account_creation_limit) || -1 + used = creations.where(principal_guid: principal.guid).where { created_at > now - WINDOW }.count + raise CloudController::Errors::V3::ApiError.new_from_details('ServiceAccountCreationLimitExceeded') if limit.between?(0, used) + + result = yield + # Independent of accounts, tombstones and audit-event retention. Failed + # creations roll this back; successful deletions never refund the budget. + creations.insert(principal_guid: principal.guid, created_at: now) # rubocop:disable Rails/SkipsModelValidations -- Transactional creation ledger, not an ActiveRecord model. + creations.where(principal_guid: principal.guid).where { created_at <= now - WINDOW }.delete + result + end + end +end diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index f2a02b7dce2..fb978e9205d 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -6,6 +6,7 @@ require 'fetchers/app_list_fetcher' require 'presenters/v3/app_presenter' require 'repositories/service_account_event_repository' +require 'actions/service_account_creation_budget' class ServiceAccountsController < ApplicationController def index @@ -42,9 +43,11 @@ def create account = nil ServiceAccountModel.db.transaction do - account = ServiceAccountModel.create(name: message.name, space: space) - apply_metadata(account, message) - Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info) + ServiceAccountCreationBudget.consume(current_user, exempt: permission_queryer.can_write_globally?) do + account = ServiceAccountModel.create(name: message.name, space: space) + apply_metadata(account, message) + Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info) + end end render status: :created, json: Presenters::V3::ServiceAccountPresenter.new(account) rescue Sequel::ValidationFailed => e diff --git a/db/migrations/20261007120000_create_service_account_creations.rb b/db/migrations/20261007120000_create_service_account_creations.rb new file mode 100644 index 00000000000..5367cc8a40d --- /dev/null +++ b/db/migrations/20261007120000_create_service_account_creations.rb @@ -0,0 +1,10 @@ +Sequel.migration do + change do + create_table :service_account_creations do + primary_key :id, name: :service_account_creations_pkey + String :principal_guid, size: 255, null: false + DateTime :created_at, null: false + index %i[principal_guid created_at], name: :service_account_creations_principal_time + end + end +end diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md index 2268c4970cf..2ac72b6a8bd 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -40,6 +40,15 @@ Minimal creation body (permitted roles: owning-space manager or platform admin): Optional creation fields are `description` and standard `metadata.labels` and `metadata.annotations`. Metadata updates use the normal merge/removal semantics. +Operators can set `service_account_creation_limit` in API configuration to a +nonnegative maximum successful creations per authenticated principal over a rolling +seven-day window. Omitted or `-1` means unlimited; `0` disables non-admin creation. +The budget spans all spaces and includes automation clients, but platform admins +and admin automation are exempt. Exhaustion returns 429 +`CF-ServiceAccountCreationLimitExceeded`. Failed creation consumes nothing; +deletion does not refund the budget. Accounting is independent of account deletion +and audit-event retention, and concurrent requests share one atomic budget. + Example account object: ```json diff --git a/errors/v3.yml b/errors/v3.yml index fa2d068213b..703a21d3b55 100644 --- a/errors/v3.yml +++ b/errors/v3.yml @@ -13,6 +13,11 @@ http_code: 409 message: "%s" +10019: + name: ServiceAccountCreationLimitExceeded + http_code: 429 + message: "Service account creation budget exhausted for this principal; retry when creations leave the rolling seven-day window" + 270010: name: ServiceBrokerNotRemovable http_code: 422 diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index 560354cc83e..a8f0849b953 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -32,6 +32,7 @@ class ApiSchema < VCAP::Config optional(:custom_root_links) => Array, optional(:service_account_provisioning_enabled) => bool, optional(:service_account_runtime_enabled) => bool, + optional(:service_account_creation_limit) => Integer, optional(:service_account_token_endpoint) => String, optional(:service_account_provisioning) => { client_id: String, diff --git a/spec/unit/actions/service_account_creation_budget_spec.rb b/spec/unit/actions/service_account_creation_budget_spec.rb new file mode 100644 index 00000000000..32e24508b08 --- /dev/null +++ b/spec/unit/actions/service_account_creation_budget_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' +require 'actions/service_account_creation_budget' + +module VCAP::CloudController + RSpec.describe ServiceAccountCreationBudget do + it 'serializes concurrent creations by the same principal across spaces', isolation: :truncation do + TestConfig.override(service_account_creation_limit: 1) + principal = create(:user) + spaces = create_list(:space, 2) + gate = Queue.new + threads = spaces.each_with_index.map do |space, index| + Thread.new do + gate.pop + ServiceAccountModel.db.transaction do + described_class.consume(User.first(guid: principal.guid), exempt: false) do + ServiceAccountModel.create(name: "worker-#{index}", space: space) + end + end + :created + rescue CloudController::Errors::V3::ApiError => e + e.name + end + end + threads.size.times { gate << true } + + expect(threads.map(&:value)).to contain_exactly(:created, 'ServiceAccountCreationLimitExceeded') + expect(ServiceAccountModel.count).to eq(1) + expect(ServiceAccountModel.db[:service_account_creations].count).to eq(1) + expect(ServiceAccountModel.db[:service_account_names].count).to eq(1) + end + end +end From 052cd6293dffecd0726b5131adc75f5bcb5a8380 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:32:13 +0200 Subject: [PATCH 55/57] Test cascading account cleanup on space and org deletion (red) --- spec/unit/actions/organization_delete_spec.rb | 32 +++++++ spec/unit/actions/space_delete_spec.rb | 89 +++++++++++++++++-- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/spec/unit/actions/organization_delete_spec.rb b/spec/unit/actions/organization_delete_spec.rb index 3d6329a71e8..e70968fc7b4 100644 --- a/spec/unit/actions/organization_delete_spec.rb +++ b/spec/unit/actions/organization_delete_spec.rb @@ -73,6 +73,38 @@ module VCAP::CloudController end describe 'recursive deletion' do + context 'service accounts' do + let!(:account) { ServiceAccountModel.create(name: 'org-worker', space: space) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca: 'identity-ca') } + + before do + TestConfig.override(service_account_provisioning_enabled: true) + CloudController::DependencyLocator.instance.register(:service_account_provisioner, provisioner) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:delete) + end + + it 'cascades account deletion through spaces and retains the name tombstone' do + app.update(service_account: account) + expect(org_delete.delete(org_dataset)).to be_empty + expect(ServiceAccountModel.first(guid: account.guid)).to be_nil + expect(Organization.first(guid: org_1.guid)).to be_nil + expect(ServiceAccountModel.db[:service_account_names].where(name: account.name).count).to eq(1) + end + + it 'keeps the organization until account cleanup succeeds' do + allow(clients).to receive(:get).and_raise('UAA unavailable') + errors = org_delete.delete(org_dataset) + expect(errors.map(&:message).join).to include('UAA unavailable') + expect(Organization.first(guid: org_1.guid)).not_to be_nil + expect(Space.first(guid: space.guid)).not_to be_nil + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + expect(org_delete.delete(org_dataset)).to be_empty + expect(Organization.first(guid: org_1.guid)).to be_nil + end + end + it 'deletes any spaces in the org' do expect do org_delete.delete(org_dataset) diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index a11c84c0536..8b632936402 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -27,13 +27,88 @@ module VCAP::CloudController expect { space.refresh }.to raise_error Sequel::Error, 'Record not found' end - it 'reports owned accounts before deleting apps or other space resources' do - account = ServiceAccountModel.create(name: 'payments-worker', space: space) - errors = space_delete.delete([space]) - expect(errors.map(&:message).join).to include('service accounts') - expect(AppModel.first(guid: app.guid)).not_to be_nil - expect(ServiceAccountModel.first(guid: account.guid)).not_to be_nil - expect(Space.first(guid: space.guid)).not_to be_nil + context 'owned service accounts' do + let!(:account) { ServiceAccountModel.create(name: 'payments-worker', space: space) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca: 'identity-ca') } + + before do + TestConfig.override(service_account_provisioning_enabled: true) + CloudController::DependencyLocator.instance.register(:service_account_provisioner, provisioner) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + allow(clients).to receive(:delete) + end + + it 'deletes assigned workloads, managed clients and principals with explicit roles, retaining names' do + provisioner.provision(account) + principal = User.first(guid: account.client_id) + space.organization.add_user(principal) + space.add_auditor(principal) + app.update(service_account: account) + process = create(:process_model, app: app, state: ProcessModel::STARTED, service_account_guid: account.guid, service_account_snapshot: true) + task = create(:task, app: app, state: TaskModel::RUNNING_STATE, service_account_guid: account.guid, service_account_snapshot: true) + allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) + allow(clients).to receive(:delete) do + expect(AppModel.first(guid: app.guid)).to be_nil + expect(ProcessModel.first(guid: process.guid)).to be_nil + expect(TaskModel.first(guid: task.guid)).to be_nil + expect(Space.first(guid: space.guid)).not_to be_nil + end + + expect(space_delete.delete([space])).to be_empty + + expect(clients).to have_received(:delete).with(:client, account.client_id) + expect(ServiceAccountModel.first(guid: account.guid)).to be_nil + expect(User.first(guid: principal.guid)).to be_nil + expect(Space.first(guid: space.guid)).to be_nil + expect(ServiceAccountModel.db[:service_account_names].where(name: account.name).count).to eq(1) + expect(Event.where(type: 'audit.service_account.delete', actee: account.guid).count).to eq(1) + end + + it 'retains the space on UAA failure and allows cleanup to be retried' do + provisioner.provision(account) + app.update(service_account: account) + allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) + allow(clients).to receive(:delete).and_raise('UAA unavailable') + + errors = space_delete.delete([space]) + + expect(errors.map(&:message).join).to include('UAA unavailable') + expect(Space.first(guid: space.guid)).not_to be_nil + expect(account.reload.enabled).to be(false) + expect(account.status).to eq('failed') + allow(clients).to receive(:delete) + expect(space_delete.delete([space])).to be_empty + expect(Space.first(guid: space.guid)).to be_nil + end + + it 'reports active account reconciliation before deleting workloads' do + PollableJobModel.create(resource_guid: account.guid, resource_type: 'service_account', operation: 'service_account.provision', state: 'POLLING') + errors = space_delete.delete([space]) + expect(errors.map(&:message).join).to include('operation is already in progress') + expect(AppModel.first(guid: app.guid)).not_to be_nil + expect(Space.first(guid: space.guid)).not_to be_nil + expect(clients).not_to have_received(:delete) + end + + it 'does not adopt or remove an unrelated UAA client' do + allow(clients).to receive(:get).and_return('client_id' => account.client_id, 'cf_service_account_guid' => 'someone-else') + errors = space_delete.delete([space]) + expect(errors.map(&:message).join).to include('client identity collision') + expect(ServiceAccountModel.first(guid: account.guid)).not_to be_nil + expect(Space.first(guid: space.guid)).not_to be_nil + expect(clients).not_to have_received(:delete) + end + + it 'retains the account and space if workload deletion fails' do + allow_any_instance_of(AppDelete).to receive(:delete).and_raise('workload cleanup failed') + errors = space_delete.delete([space]) + expect(errors.map(&:message).join).to include('workload cleanup failed') + expect(ServiceAccountModel.first(guid: account.guid)).not_to be_nil + expect(Space.first(guid: space.guid)).not_to be_nil + expect(clients).not_to have_received(:delete) + end end it 'creates audit events for recursive app deletion and space deletion' do From c3c53dfec3c887cfc5cbae3244255354c42f160e Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:34:27 +0200 Subject: [PATCH 56/57] Correct task fixture and confirm assigned workload cascade failure (red) --- spec/unit/actions/space_delete_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index 8b632936402..29f04cded0a 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -47,7 +47,7 @@ module VCAP::CloudController space.add_auditor(principal) app.update(service_account: account) process = create(:process_model, app: app, state: ProcessModel::STARTED, service_account_guid: account.guid, service_account_snapshot: true) - task = create(:task, app: app, state: TaskModel::RUNNING_STATE, service_account_guid: account.guid, service_account_snapshot: true) + task = create(:task_model, app: app, state: TaskModel::RUNNING_STATE, service_account_guid: account.guid, service_account_snapshot: true) allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) allow(clients).to receive(:delete) do expect(AppModel.first(guid: app.guid)).to be_nil From ec582b703ef80fa9b9c5b3c34ca2798a29b4b801 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:39:25 +0200 Subject: [PATCH 57/57] Cascade owned service account cleanup during space and org deletion --- app/actions/space_delete.rb | 42 ++++++++++++++++--- .../api_resources/_service_accounts.md | 8 ++-- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/app/actions/space_delete.rb b/app/actions/space_delete.rb index 19111a769f1..707d0aa683a 100644 --- a/app/actions/space_delete.rb +++ b/app/actions/space_delete.rb @@ -1,5 +1,6 @@ require 'actions/v3/service_instance_delete' require 'jobs/v3/delete_service_instance_job' +require 'repositories/service_account_event_repository' module VCAP::CloudController class SpaceDelete @@ -10,11 +11,6 @@ def initialize(user_audit_info, services_event_repository) def delete(dataset) dataset.each_with_object([]) do |space_model, errors| - if ServiceAccountModel.where(space_guid: space_model.guid).any? - errors << CloudController::Errors::ApiError.new_from_details('SpaceDeletionFailed', space_model.name, 'Delete owned service accounts before deleting the space.') - next - end - instance_delete_errors = delete_service_instances(space_model) err = accumulate_space_deletion_error(instance_delete_errors, space_model.name) errors << err unless err.nil? @@ -29,6 +25,11 @@ def delete(dataset) next unless instance_delete_errors.empty? && instance_unshare_errors.empty? + account_delete_errors = delete_service_accounts(space_model) + err = accumulate_space_deletion_error(account_delete_errors, space_model.name) + errors << err unless err.nil? + next unless account_delete_errors.empty? + Space.db.transaction do delete_apps(space_model) space_model.destroy @@ -81,7 +82,36 @@ def unshare_service_instances(space_model) end def delete_apps(space_model) - AppDelete.new(@user_audit_info).delete(space_model.app_models) + AppDelete.new(@user_audit_info).delete(space_model.app_models_dataset.all) + end + + def delete_service_accounts(space_model) + accounts = ServiceAccountModel.where(space_guid: space_model.guid).order(:guid).all + return [] if accounts.empty? + + ServiceAccountModel.db.transaction do + accounts.each do |account| + account.lock! + if PollableJobModel.where(resource_guid: account.guid, resource_type: 'service_account', state: %w[PROCESSING POLLING]).any? + raise ServiceAccountProvision::Conflict.new('service account operation is already in progress') + end + + account.update(enabled: false, status: 'deleting') + end + end + + # AppDelete removes service bindings, tasks and process snapshots. Do not + # roll this back if later UAA cleanup fails: retries must see real progress. + delete_apps(space_model) + provisioner = CloudController::DependencyLocator.instance.service_account_provisioner + accounts.each_with_object([]) do |account, errors| + Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) + provisioner.deprovision(account, delete: true) + rescue StandardError => e + errors << e + end + rescue StandardError => e + [e] end def delete_service_brokers(space_model) diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md index 2ac72b6a8bd..ef822789f43 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -110,9 +110,11 @@ task snapshots. The worker rechecks usage and registration trust before removing the client, principal and account. The name reservation is retained permanently. Concurrent lifecycle operations return 409. All asynchronous operations use the standard job polling endpoint. -Delete owned accounts explicitly before deleting their space. Recursive space -deletion reports an error before deleting that space's apps or service resources -when an account remains. +Space/org deletion cascades owned accounts: service bindings and workloads are +deleted before managed clients, principals/roles and accounts; name reservations +remain. Active account operations and cleanup failures prevent final parent +deletion and are reported by the deletion job. Retry the parent deletion after +resolving the failure; completed cleanup is not rolled back. ## Runtime and rollout