From a0501f59d178ccb36b99c38cce5e21af5afc7225 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:30:48 +0200 Subject: [PATCH 001/200] 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 6d14ea85df09ae7e71cbaa8e02e3a769028aa1e2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:33:00 +0200 Subject: [PATCH 002/200] 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 cdefb16b438..d87a4399943 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 d7d6353e0f1d9d5cbdfd24520f64740d5c99d499 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:46:24 +0200 Subject: [PATCH 003/200] 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 668c4f082fdfae06dc513eec1349159a9e479784 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:48:15 +0200 Subject: [PATCH 004/200] 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 9008ffdf7f8ea91be5181ccdda6a8c5325488d87 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:52:35 +0200 Subject: [PATCH 005/200] 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 d78294c6fcfe8c2846ba7076efcab07c306b5c0b Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 18:56:03 +0200 Subject: [PATCH 006/200] 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 c8a5c2dff1087c79b83d2ff57bf15ec4e4858b8c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:14:43 +0200 Subject: [PATCH 007/200] 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 6aa4ca0a6a57021fb84692c2c0a272e28fb08a51 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:17:19 +0200 Subject: [PATCH 008/200] 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 91fff36e9b12f8c11f9b1142e01ee467108037f4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:26:42 +0200 Subject: [PATCH 009/200] 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 4428266ebbbbf43d69b12ec62dfb6167f6e929de Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:28:18 +0200 Subject: [PATCH 010/200] 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 1f22e7197085e13e75cba85657ae872ddd9795da Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:34:46 +0200 Subject: [PATCH 011/200] 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 ed54722ccc92caef6ca06d3a93e6b716de61b6f6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:40:19 +0200 Subject: [PATCH 012/200] 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 c60807a5f75..920219d219e 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 36d439c102139e5b94cc90ff6f412ee497ef2059 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:43:06 +0200 Subject: [PATCH 013/200] 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 0bc48a6a0dd2b2b2182a1a9fe74e32787250ecd4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:47:12 +0200 Subject: [PATCH 014/200] 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 acb517879da..6e6a3ed6da5 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 9c4a10106bc04351f9a08ff7e755a0822c9a8d1e Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:48:54 +0200 Subject: [PATCH 015/200] 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 98dbf8d2b89f5a6b9dad2e35c4a922dc09ace5e1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:56:05 +0200 Subject: [PATCH 016/200] 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 fee96b0ee0b23b4553f514eba6d27433601abadb Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 19:57:38 +0200 Subject: [PATCH 017/200] 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 19167e5f91c96759a3de6173f9644b5eb3e26c83 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:00:26 +0200 Subject: [PATCH 018/200] 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 88182e4920d4218be0da977ef29098c398fe4b13 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:01:57 +0200 Subject: [PATCH 019/200] 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 01a0cf9f313f2edd51f13e01ea1af873a594a285 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:03:41 +0200 Subject: [PATCH 020/200] 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 6698b22016414b69fe9a6a613bae3699a6522a6f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:07:07 +0200 Subject: [PATCH 021/200] 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 3e1e2891f5f9fcef842c9deaea9638472aad4189 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:08:41 +0200 Subject: [PATCH 022/200] 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 c885c1f90d4768a11268ec8701428c50da72823b Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:20:35 +0200 Subject: [PATCH 023/200] 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 85544290d8401a68d835a583486da214cf3e4500 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:24:20 +0200 Subject: [PATCH 024/200] 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 6e6a3ed6da5..4a45b609ca1 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 79c8de01bee..7793c0160ae 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 920219d219e..3ce2ee1524d 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 9b190e08de091dd19eff83dde976103ce6d74fb1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:28:04 +0200 Subject: [PATCH 025/200] 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 1047b185402..f8a9ecfb904 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 @@ -540,6 +540,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 ae7c301bb335b9827bb85e83268ff52d73adf9b3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:31:09 +0200 Subject: [PATCH 026/200] 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 cedf097dd06..ba5fd2da116 100644 --- a/lib/cloud_controller/diego/app_recipe_builder.rb +++ b/lib/cloud_controller/diego/app_recipe_builder.rb @@ -11,6 +11,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 @@ -99,9 +100,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, **lrp_image_credentials, volume_mounted_files: ServiceBindingFilesBuilder.build(process) }.compact 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 4e55e677a09..8399e2a5057 100644 --- a/lib/cloud_controller/diego/task_recipe_builder.rb +++ b/lib/cloud_controller/diego/task_recipe_builder.rb @@ -7,6 +7,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 @@ -46,13 +47,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, **task_image_credentials(task), volume_mounted_files: ServiceBindingFilesBuilder.build(task.app) }.compact) 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 f8a9ecfb904..71404d1d30d 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 @@ -540,13 +540,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 @@ -571,6 +564,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 ce5df05a2634a0bcbe4a4e62ef8f2183d8e091e6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:32:34 +0200 Subject: [PATCH 027/200] 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 9b74065f6452dbaf1f83b6aa972bb606af5fdba6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:36:44 +0200 Subject: [PATCH 028/200] 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 8399e2a5057..785b9d22aab 100644 --- a/lib/cloud_controller/diego/task_recipe_builder.rb +++ b/lib/cloud_controller/diego/task_recipe_builder.rb @@ -47,7 +47,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, **task_image_credentials(task), volume_mounted_files: ServiceBindingFilesBuilder.build(task.app) }.compact) From d103ed9e4ff0ce4d76d2e209e749297a18b25f7a Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:38:16 +0200 Subject: [PATCH 029/200] 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 3e3a3d6bacaded34ece0c27f49a351459ab35c18 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:40:39 +0200 Subject: [PATCH 030/200] 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 ba5fd2da116..1a628165124 100644 --- a/lib/cloud_controller/diego/app_recipe_builder.rb +++ b/lib/cloud_controller/diego/app_recipe_builder.rb @@ -100,7 +100,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, **lrp_image_credentials, volume_mounted_files: ServiceBindingFilesBuilder.build(process) }.compact 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 37b08b84655b0678b01dce179d65a881064f6244 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:46:58 +0200 Subject: [PATCH 031/200] 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 f9c0c9da31b9756e7a03ac82308eba304333a016 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:48:19 +0200 Subject: [PATCH 032/200] 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 9396e4f059c576e3f5ab8bdce85cebd70ae20d55 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:49:34 +0200 Subject: [PATCH 033/200] 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 2773e73dbb06d2ad8b2b004e6a833f6598d9a966 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:52:25 +0200 Subject: [PATCH 034/200] 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 db812b5717f2d6193eb8305b8b80e75fbf44e44f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:56:15 +0200 Subject: [PATCH 035/200] 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 48d64c8dcff1418055c1d72e8e0b0b2af44ebc21 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 20:59:01 +0200 Subject: [PATCH 036/200] 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 bd5c54de693d31be06dcb3fd08569c2536fbb1a4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:00:16 +0200 Subject: [PATCH 037/200] 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 201ab9568e0980d67da729b8d9632285285bd4b6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:01:38 +0200 Subject: [PATCH 038/200] 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 4a45b609ca1..8da0e75e2fc 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 b576bf8296c84d828784f71f8258ddb00c7531ec Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:03:06 +0200 Subject: [PATCH 039/200] 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 71404d1d30d..03a4bbfb30b 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 @@ -568,6 +568,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 2198d8c76ce34a4110dcedad2a6ba9dfeba21380 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:04:50 +0200 Subject: [PATCH 040/200] 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 46eb62414716a99f39a522ce482e3c899ff44fb8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:08:21 +0200 Subject: [PATCH 041/200] 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 9bc0cc1d33847b11542b5b7068e99ea6b3fddfec Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:09:44 +0200 Subject: [PATCH 042/200] 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 e4bf7acb411f6edd8b6f5802ab4599ffca97a0d6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:11:08 +0200 Subject: [PATCH 043/200] 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 b4cea5496e88369d00878dfcd4c12bd3672b91db Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:12:32 +0200 Subject: [PATCH 044/200] 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 1726f834544eb79a4e77cf9ecdd957226b2ba652 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:13:46 +0200 Subject: [PATCH 045/200] 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 817c5a6dfb318ba995be27d38634e403a7d9b1b7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 21:15:08 +0200 Subject: [PATCH 046/200] 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 19adbae699abd101d9e56c1aa7299b04360e853d Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 22:33:52 +0200 Subject: [PATCH 047/200] 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 15b650d2fa1..f38aebcb629 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 4bccc9b228f71e2cf979b9ffededb6abd55bccdb Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 5 Oct 2026 22:39:16 +0200 Subject: [PATCH 048/200] 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 f014a3b5af310568e06e76c7bd915116148a7cf4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 07:19:23 +0200 Subject: [PATCH 049/200] 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 f7786f5248bd3c149fb02abd0cd1d5e5bf90f633 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 07:21:56 +0200 Subject: [PATCH 050/200] 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 7a16b72a4a2a9e1d4a9aec4b28d4d79f68c61491 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 08:23:22 +0200 Subject: [PATCH 051/200] 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 d972bc3284888ee5242d4a74730b3f4936d90aff Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 6 Oct 2026 08:24:50 +0200 Subject: [PATCH 052/200] 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 b6a363468d1bd193386b90105e40b706e0635a80 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:23:43 +0200 Subject: [PATCH 053/200] 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 9fe3e794f802b4b9b40e2156e059ad18f92c85a0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:29:25 +0200 Subject: [PATCH 054/200] 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 8da0e75e2fc..73817a0bf85 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 9642e1cc88b0f9276116eac29f13dca6214fc4ea Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:32:13 +0200 Subject: [PATCH 055/200] 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 1943c864b8a3deb7dd2facb992f50965be0f7291 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:34:27 +0200 Subject: [PATCH 056/200] 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 fa942e9144afcfe98b91af9c455ae8d4f5bd1018 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 7 Oct 2026 09:39:25 +0200 Subject: [PATCH 057/200] 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 From ae39ff03b48b5ca2e42bf3891646c171cf187662 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 10:53:54 +0200 Subject: [PATCH 058/200] Test registering account clients by UAA trust bundle reference (red) --- .../actions/service_account_provision_spec.rb | 26 ++++++++++++ ..._account_provisioner_configuration_spec.rb | 42 +++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index d016543e43f..cbd51c00329 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -86,6 +86,32 @@ module VCAP::CloudController expect(account.reload.status).to eq('reserved') end + context 'when trust is configured as a UAA trust bundle reference' do + let(:action) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } + let(:referenced) { registration.except('tls-client-auth-ca').merge('tls-client-auth-ca-ref' => 'diego-instance-identity') } + + it 'registers the bundle reference instead of inline CA certificates' do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + expect(clients).to receive(:add).with(:client, referenced) + action.provision(account) + expect(account.reload.status).to eq('ready') + end + + it 'reuses a matching referenced registration' do + allow(clients).to receive(:get).and_return(referenced) + expect(clients).not_to receive(:add) + action.provision(account) + expect(account.reload.status).to eq('ready') + end + end + + [{}, { identity_ca: 'trusted-ca', identity_ca_ref: 'diego-instance-identity' }, { identity_ca: ' ' }, { identity_ca_ref: '' }].each do |trust| + it "requires exactly one nonblank trust setting (#{trust.keys.join(', ').presence || 'none'})" do + expect { ServiceAccountProvision.new(clients, **trust) }. + to raise_error(ArgumentError, /exactly one of identity_ca or identity_ca_ref/) + end + 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') } 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 1114b96d1c3..de8de87fd9c 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 @@ -20,22 +20,58 @@ module VCAP::CloudController 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/) + api_config = Config.read_file('config/cloud_controller.yml').merge(service_account_provisioning: settings.except(:client_id)) + expect { ConfigSchemas::ApiSchema.validate(api_config) }.to raise_error(Membrane::SchemaValidationError, /client_id => Missing key/) end - %i[client_id client_secret identity_ca].each do |key| + %i[client_id client_secret].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 + end + %i[client_id client_secret identity_ca].each do |key| 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 + context 'with a UAA trust bundle reference instead of inline CA certificates' do + let(:settings) { { client_id: 'account-manager', client_secret: 'manager-secret', identity_ca_ref: 'diego-instance-identity' } } + + it 'accepts the reference in worker and API configuration' do + expect { ConfigSchemas::WorkerSchema.validate(worker_config) }.not_to raise_error + api_config = Config.read_file('config/cloud_controller.yml').merge(service_account_provisioning: settings) + expect { ConfigSchemas::ApiSchema.validate(api_config) }.not_to raise_error + end + + it 'registers account clients with the configured reference' do + WebMock::API.stub_request(:post, "#{uaa_url}/oauth/token"). + 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}").to_return(status: 404) + creation = WebMock::API.stub_request(:post, "#{uaa_url}/oauth/clients").with do |request| + payload = Oj.load(request.body) + payload['tls-client-auth-ca-ref'] == 'diego-instance-identity' && !payload.key?('tls-client-auth-ca') + end.to_return(status: 201, headers: headers, body: { client_id: account.client_id }.to_json) + + locator.service_account_provisioner.provision(account) + + expect(creation).to have_been_requested.once + end + end + + it 'fails closed when both inline CA certificates and a bundle reference are configured' do + TestConfig.override(service_account_provisioning: settings.merge(identity_ca_ref: 'diego-instance-identity')) + expect { locator.service_account_provisioner }.to raise_error(/service account provisioning is not configured/) + end + + it 'fails closed when neither inline CA certificates nor a bundle reference are configured' do + TestConfig.override(service_account_provisioning: settings.except(:identity_ca)) + expect { locator.service_account_provisioner }.to raise_error(/service account provisioning is not configured/) + 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' }). From dc0ce610cdf247ccba34efa7d2052f9698207f13 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 10:57:22 +0200 Subject: [PATCH 059/200] Register account clients by UAA trust bundle reference when configured --- app/actions/service_account_provision.rb | 14 +++++++++----- lib/cloud_controller/config_schemas/api_schema.rb | 3 ++- .../config_schemas/worker_schema.rb | 3 ++- lib/cloud_controller/dependency_locator.rb | 7 +++++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index 47b6166743e..1fa9ccc34c9 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -2,9 +2,13 @@ module VCAP::CloudController class ServiceAccountProvision class Conflict < StandardError; end - def initialize(clients, identity_ca:) + def initialize(clients, identity_ca: nil, identity_ca_ref: nil) + trust = { 'tls-client-auth-ca' => identity_ca, 'tls-client-auth-ca-ref' => identity_ca_ref }.compact_blank + raise ArgumentError.new('exactly one of identity_ca or identity_ca_ref must be configured') unless trust.size == 1 + @clients = clients - @identity_ca = identity_ca + # Inline PEM, or the name of an operator-defined UAA trust bundle (rotated in UAA without client updates). + @trust = trust end def provision(account) @@ -102,11 +106,11 @@ def registration(account) 'authorized_grant_types' => ['client_credentials'], 'authorities' => %w[cloud_controller.read cloud_controller.write], 'scope' => [], - 'access_token_validity' => 300, - 'tls-client-auth-ca' => @identity_ca, + 'access_token_validity' => 300 + }.merge(@trust).merge( 'tls_client_auth_san_dns' => account.certificate_dns_san, 'cf_service_account_guid' => account.guid - } + ) end end end diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index 73817a0bf85..4eb755da65e 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -37,7 +37,8 @@ class ApiSchema < VCAP::Config optional(:service_account_provisioning) => { client_id: String, client_secret: String, - identity_ca: String + optional(:identity_ca) => String, + optional(:identity_ca_ref) => String }, system_domain: String, diff --git a/lib/cloud_controller/config_schemas/worker_schema.rb b/lib/cloud_controller/config_schemas/worker_schema.rb index 3ce2ee1524d..086f25935dc 100644 --- a/lib/cloud_controller/config_schemas/worker_schema.rb +++ b/lib/cloud_controller/config_schemas/worker_schema.rb @@ -32,7 +32,8 @@ class WorkerSchema < VCAP::Config optional(:service_account_provisioning) => { client_id: String, client_secret: String, - identity_ca: String + optional(:identity_ca) => String, + optional(:identity_ca_ref) => String }, optional(:service_account_runtime_enabled) => bool, optional(:service_account_token_endpoint) => String, diff --git a/lib/cloud_controller/dependency_locator.rb b/lib/cloud_controller/dependency_locator.rb index 37c5216f3f4..be823c7acbd 100644 --- a/lib/cloud_controller/dependency_locator.rb +++ b/lib/cloud_controller/dependency_locator.rb @@ -311,7 +311,9 @@ def service_account_provisioner 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? } + configured = ->(key) { settings[key].is_a?(String) && settings[key].present? } + unless settings.is_a?(Hash) && %i[client_id client_secret].all?(&configured) && + %i[identity_ca identity_ca_ref].one?(&configured) && %i[identity_ca identity_ca_ref].none? { |key| settings.key?(key) && !configured.call(key) } raise 'service account provisioning is not configured' end @@ -321,7 +323,8 @@ def service_account_provisioner secret: settings[:client_secret], ca_file: config.get(:uaa, :ca_file) ) - register(:service_account_provisioner, ServiceAccountProvision.new(clients, identity_ca: settings[:identity_ca])) + register(:service_account_provisioner, + ServiceAccountProvision.new(clients, identity_ca: settings[:identity_ca], identity_ca_ref: settings[:identity_ca_ref])) end def uaa_shadow_user_creation_client From 507428393882c0cbec6cdde002cc8804171d121e Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 10:58:32 +0200 Subject: [PATCH 060/200] Test reconciling trust-anchor drift on owned account clients (red) --- .../actions/service_account_provision_spec.rb | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index cbd51c00329..6e026c2dd45 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -103,6 +103,49 @@ module VCAP::CloudController action.provision(account) expect(account.reload.status).to eq('ready') end + + it 'migrates its own inline-CA registration to the reference, clearing the inline CA' do + allow(clients).to receive(:get).and_return(registration) + expect(clients).not_to receive(:add) + expect(clients).to receive(:put).with(:client, referenced.merge('tls-client-auth-ca' => nil)) + action.provision(account) + expect(account.reload.status).to eq('ready') + end + + it 'deletes its own registration with a drifted trust anchor when disabling' do + account.update(enabled: false) + allow(clients).to receive(:get).and_return(registration) + 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 unmarked client that differs only in trust anchor' do + allow(clients).to receive(:get).and_return(registration.except('cf_service_account_guid')) + expect(clients).not_to receive(:put) + expect { action.provision(account) }.to raise_error(/client identity collision/) + expect(account.reload.status).to eq('failed') + end + + it 'does not adopt a marked client that also differs in other policy' do + allow(clients).to receive(:get).and_return(registration.merge('authorities' => %w[cloud_controller.admin])) + expect(clients).not_to receive(:put) + expect { action.provision(account) }.to raise_error(/client identity collision/) + end + end + + it 'updates its own registration when the configured inline CA bundle changes' do + allow(clients).to receive(:get).and_return(registration.merge('tls-client-auth-ca' => 'previous-ca')) + expect(clients).to receive(:put).with(:client, registration.merge('tls-client-auth-ca-ref' => nil)) + action.provision(account) + expect(account.reload.status).to eq('ready') + end + + it 'migrates its own referenced registration back to inline CA certificates' do + allow(clients).to receive(:get).and_return(registration.except('tls-client-auth-ca').merge('tls-client-auth-ca-ref' => 'diego-instance-identity')) + expect(clients).to receive(:put).with(:client, registration.merge('tls-client-auth-ca-ref' => nil)) + action.provision(account) + expect(account.reload.status).to eq('ready') end [{}, { identity_ca: 'trusted-ca', identity_ca_ref: 'diego-instance-identity' }, { identity_ca: ' ' }, { identity_ca_ref: '' }].each do |trust| @@ -130,6 +173,20 @@ module VCAP::CloudController expect(account.reload.status).to eq('ready') end + it 'sends an explicit null to clear the replaced trust anchor in UAA' do + WebMock::API.stub_request(:get, client_url). + to_return(headers: response_headers, body: registration.merge('tls-client-auth-ca' => 'previous-ca').to_json) + update = WebMock::API.stub_request(:put, client_url).with do |request| + payload = Oj.load(request.body) + payload['tls-client-auth-ca'] == 'trusted-ca' && payload.key?('tls-client-auth-ca-ref') && payload['tls-client-auth-ca-ref'].nil? + end.to_return(headers: response_headers, body: registration.to_json) + + action.provision(account) + + expect(update).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). From 3958f068b38d62591b03a8f2737dceb2a83f85c1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 11:03:17 +0200 Subject: [PATCH 061/200] Update owned account clients on trust-anchor drift instead of rejecting them --- app/actions/service_account_provision.rb | 24 +++++++++++++++---- .../api_resources/_service_accounts.md | 15 ++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index 1fa9ccc34c9..c2f80a2602b 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -2,6 +2,8 @@ module VCAP::CloudController class ServiceAccountProvision class Conflict < StandardError; end + TRUST_KEYS = %w[tls-client-auth-ca tls-client-auth-ca-ref].freeze + def initialize(clients, identity_ca: nil, identity_ca_ref: nil) trust = { 'tls-client-auth-ca' => identity_ca, 'tls-client-auth-ca-ref' => identity_ca_ref }.compact_blank raise ArgumentError.new('exactly one of identity_ca or identity_ca_ref must be configured') unless trust.size == 1 @@ -24,10 +26,13 @@ def provision(account) desired = registration(account) existing = existing_client(account.client_id) - if existing - raise Conflict.new('client identity collision') unless matching_registration?(existing, desired) - else + if existing.nil? @clients.add(:client, desired) + elsif !matching_registration?(existing, desired) + raise Conflict.new('client identity collision') unless trust_anchor_drift_only?(existing, desired) + + # UAA merges client updates: clear the replaced trust setting explicitly. + @clients.put(:client, desired.merge((TRUST_KEYS - desired.keys).index_with(nil))) end User.create(guid: account.client_id, is_oauth_client: true, active: true) unless principal @@ -57,7 +62,7 @@ def deprovision(account, delete: false) existing = existing_client(account.client_id) if existing - raise Conflict.new('client identity collision') unless matching_registration?(existing, registration(account)) + raise Conflict.new('client identity collision') unless owned_client?(existing, registration(account)) @clients.delete(:client, account.client_id) end @@ -78,6 +83,17 @@ def deprovision(account, delete: false) private + # A client carrying this account's marker whose only difference is its trust anchor (inline CA + # bundle changed, or switched between inline CA and a UAA trust bundle reference) is ours to update. + # Every other policy difference, or a missing marker, remains a collision. + def trust_anchor_drift_only?(existing, desired) + matching_registration?(existing.except(*TRUST_KEYS), desired.except(*TRUST_KEYS)) + end + + def owned_client?(existing, desired) + matching_registration?(existing, desired) || trust_anchor_drift_only?(existing, desired) + end + 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 diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md index ef822789f43..f6a1b8ed2a7 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -89,7 +89,10 @@ 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 +Client collisions are rejected rather than overwritten/adopted. The one exception +is a client carrying the account's GUID marker whose only difference is its trust +anchor (changed inline CA bundle, or a switch between inline CA and a UAA trust +bundle reference): it is updated in place, clearing the replaced setting. 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. @@ -136,9 +139,13 @@ BOSH properties under `cc.service_accounts`: - `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. +- Worker-only `management_client_id`, `management_client_secret`: dedicated UAA + management credentials. Configure namespace protection independently in UAA. +- Worker-only trust, exactly one of: `identity_ca_ref`, the name of an operator-defined + UAA trust bundle (`uaa.mtls.trust_bundles`) containing the instance identity CA, + preferred because CA rotation is then a UAA configuration change; or `identity_ca`, + the instance identity CA PEM copied into every registration. Existing clients are + updated when next provisioned (first bind or enable); there is no background sweep. App-hosted local workers may receive equivalent `service_account_provisioning` configuration separately; the API BOSH template does not render management secrets. From e059a987680ea8cd8413d34a5bc47f24f43c4a6f Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 11:23:59 +0200 Subject: [PATCH 062/200] Test updating owned account clients through the management client (red) --- ..._account_provisioner_configuration_spec.rb | 24 +++++++++++++++++++ 1 file changed, 24 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 de8de87fd9c..84c241fdcbf 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 @@ -60,6 +60,30 @@ module VCAP::CloudController expect(creation).to have_been_requested.once end + + it 'updates an owned inline-CA client to the reference through the configured management client' do + WebMock::API.stub_request(:post, "#{uaa_url}/oauth/token"). + to_return(headers: headers, body: { access_token: 'manager-token', token_type: 'bearer', expires_in: 300 }.to_json) + existing = { + client_id: account.client_id, authorized_grant_types: ['client_credentials'], + authorities: %w[cloud_controller.read cloud_controller.write], access_token_validity: 300, + 'tls-client-auth-ca' => 'instance-identity-ca', tls_client_auth_san_dns: account.certificate_dns_san, + cf_service_account_guid: account.guid + } + WebMock::API.stub_request(:get, "#{uaa_url}/oauth/clients/#{account.client_id}"). + to_return(headers: headers, body: existing.to_json) + update = WebMock::API.stub_request(:put, "#{uaa_url}/oauth/clients/#{account.client_id}"). + with(headers: { 'Authorization' => 'bearer manager-token' }) do |request| + payload = Oj.load(request.body) + payload['tls-client-auth-ca-ref'] == 'diego-instance-identity' && + payload.key?('tls-client-auth-ca') && payload['tls-client-auth-ca'].nil? + end.to_return(headers: headers, body: existing.to_json) + + locator.service_account_provisioner.provision(account) + + expect(update).to have_been_requested.once + expect(account.reload.status).to eq('ready') + end end it 'fails closed when both inline CA certificates and a bundle reference are configured' do From 6a0864e6c55021e6eb1943b8f3d48839edcd1d73 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 11:25:52 +0200 Subject: [PATCH 063/200] Expose client updates on the service account management client --- lib/cloud_controller/uaa/service_account_client.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/cloud_controller/uaa/service_account_client.rb b/lib/cloud_controller/uaa/service_account_client.rb index 81646f6cc0a..b571808e6d5 100644 --- a/lib/cloud_controller/uaa/service_account_client.rb +++ b/lib/cloud_controller/uaa/service_account_client.rb @@ -14,6 +14,12 @@ def add(type, registration) with_cache_retry { scim.add(type, registration) } end + def put(type, registration) + raise ArgumentError.new('only client resources are supported') unless type == :client + + with_cache_retry { scim.put(type, registration) } + end + def delete(type, id) raise ArgumentError.new('only client resources are supported') unless type == :client From d962886950cc725807365bc5ee451400a0743dd8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 11:42:16 +0200 Subject: [PATCH 064/200] Test requiring UAA trust bundle references for account clients (red) --- spec/request/app_service_accounts_spec.rb | 2 +- spec/request/service_account_roles_spec.rb | 2 +- spec/request/service_accounts_spec.rb | 2 +- spec/unit/actions/organization_delete_spec.rb | 2 +- .../actions/service_account_provision_spec.rb | 93 +++++-------------- spec/unit/actions/space_delete_spec.rb | 2 +- .../jobs/v3/service_account_provision_spec.rb | 2 +- ..._account_provisioner_configuration_spec.rb | 68 ++------------ 8 files changed, 37 insertions(+), 136 deletions(-) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index 0f9ce6fdd61..0b92bbca46a 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -78,7 +78,7 @@ def headers(role) TestConfig.override(service_account_provisioning_enabled: true) CloudController::DependencyLocator.instance.register( :service_account_provisioner, - VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca: 'identity-ca') + VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') ) account.update(status: 'reserved') allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) diff --git a/spec/request/service_account_roles_spec.rb b/spec/request/service_account_roles_spec.rb index 37485b203a3..ea8e1ed085c 100644 --- a/spec/request/service_account_roles_spec.rb +++ b/spec/request/service_account_roles_spec.rb @@ -10,7 +10,7 @@ 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) + VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity').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) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 78dc6e041b5..4787d45e1bd 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -250,7 +250,7 @@ def headers(role) 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(:provisioner) { VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } let(:account_path) { "/v3/service_accounts/#{account.guid}" } before do diff --git a/spec/unit/actions/organization_delete_spec.rb b/spec/unit/actions/organization_delete_spec.rb index e70968fc7b4..19596e89175 100644 --- a/spec/unit/actions/organization_delete_spec.rb +++ b/spec/unit/actions/organization_delete_spec.rb @@ -76,7 +76,7 @@ module VCAP::CloudController 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') } + let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } before do TestConfig.override(service_account_provisioning_enabled: true) diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index 6e026c2dd45..03cd1411035 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -5,7 +5,7 @@ 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(:action) { VCAP::CloudController.const_get(:ServiceAccountProvision).new(clients, identity_ca_ref: 'diego-instance-identity') } let(:registration) do { 'client_id' => account.client_id, @@ -13,7 +13,7 @@ module VCAP::CloudController 'authorities' => %w[cloud_controller.read cloud_controller.write], 'scope' => [], 'access_token_validity' => 300, - 'tls-client-auth-ca' => 'trusted-ca', + 'tls-client-auth-ca-ref' => 'diego-instance-identity', 'tls_client_auth_san_dns' => account.certificate_dns_san, 'cf_service_account_guid' => account.guid } @@ -86,72 +86,35 @@ module VCAP::CloudController expect(account.reload.status).to eq('reserved') end - context 'when trust is configured as a UAA trust bundle reference' do - let(:action) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } - let(:referenced) { registration.except('tls-client-auth-ca').merge('tls-client-auth-ca-ref' => 'diego-instance-identity') } - - it 'registers the bundle reference instead of inline CA certificates' do - allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) - expect(clients).to receive(:add).with(:client, referenced) - action.provision(account) - expect(account.reload.status).to eq('ready') - end - - it 'reuses a matching referenced registration' do - allow(clients).to receive(:get).and_return(referenced) - expect(clients).not_to receive(:add) - action.provision(account) - expect(account.reload.status).to eq('ready') + it 'never registers inline CA certificates' do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + expect(clients).to receive(:add) do |_, payload| + expect(payload).not_to have_key('tls-client-auth-ca') + expect(payload['tls-client-auth-ca-ref']).to eq('diego-instance-identity') end + action.provision(account) + end - it 'migrates its own inline-CA registration to the reference, clearing the inline CA' do - allow(clients).to receive(:get).and_return(registration) + [ + { 'tls-client-auth-ca-ref' => 'previous-bundle' }, + { 'tls-client-auth-ca-ref' => nil, 'tls-client-auth-ca' => 'inline-ca' } + ].each do |altered_trust| + it "refuses its own marked client with a different trust anchor (#{altered_trust.compact.keys.first})" do + allow(clients).to receive(:get).and_return(registration.merge(altered_trust).compact) expect(clients).not_to receive(:add) - expect(clients).to receive(:put).with(:client, referenced.merge('tls-client-auth-ca' => nil)) - action.provision(account) - expect(account.reload.status).to eq('ready') - end - - it 'deletes its own registration with a drifted trust anchor when disabling' do - account.update(enabled: false) - allow(clients).to receive(:get).and_return(registration) - 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 unmarked client that differs only in trust anchor' do - allow(clients).to receive(:get).and_return(registration.except('cf_service_account_guid')) - expect(clients).not_to receive(:put) expect { action.provision(account) }.to raise_error(/client identity collision/) expect(account.reload.status).to eq('failed') end - - it 'does not adopt a marked client that also differs in other policy' do - allow(clients).to receive(:get).and_return(registration.merge('authorities' => %w[cloud_controller.admin])) - expect(clients).not_to receive(:put) - expect { action.provision(account) }.to raise_error(/client identity collision/) - end - end - - it 'updates its own registration when the configured inline CA bundle changes' do - allow(clients).to receive(:get).and_return(registration.merge('tls-client-auth-ca' => 'previous-ca')) - expect(clients).to receive(:put).with(:client, registration.merge('tls-client-auth-ca-ref' => nil)) - action.provision(account) - expect(account.reload.status).to eq('ready') end - it 'migrates its own referenced registration back to inline CA certificates' do - allow(clients).to receive(:get).and_return(registration.except('tls-client-auth-ca').merge('tls-client-auth-ca-ref' => 'diego-instance-identity')) - expect(clients).to receive(:put).with(:client, registration.merge('tls-client-auth-ca-ref' => nil)) - action.provision(account) - expect(account.reload.status).to eq('ready') + it 'only supports UAA trust bundle references' do + expect { ServiceAccountProvision.new(clients, identity_ca: 'inline-ca') }.to raise_error(ArgumentError) end - [{}, { identity_ca: 'trusted-ca', identity_ca_ref: 'diego-instance-identity' }, { identity_ca: ' ' }, { identity_ca_ref: '' }].each do |trust| - it "requires exactly one nonblank trust setting (#{trust.keys.join(', ').presence || 'none'})" do - expect { ServiceAccountProvision.new(clients, **trust) }. - to raise_error(ArgumentError, /exactly one of identity_ca or identity_ca_ref/) + [nil, '', ' '].each do |reference| + it "requires a nonblank trust bundle reference (#{reference.inspect})" do + expect { ServiceAccountProvision.new(clients, identity_ca_ref: reference) }. + to raise_error(ArgumentError, /identity_ca_ref must name a UAA trust bundle/) end end @@ -173,20 +136,6 @@ module VCAP::CloudController expect(account.reload.status).to eq('ready') end - it 'sends an explicit null to clear the replaced trust anchor in UAA' do - WebMock::API.stub_request(:get, client_url). - to_return(headers: response_headers, body: registration.merge('tls-client-auth-ca' => 'previous-ca').to_json) - update = WebMock::API.stub_request(:put, client_url).with do |request| - payload = Oj.load(request.body) - payload['tls-client-auth-ca'] == 'trusted-ca' && payload.key?('tls-client-auth-ca-ref') && payload['tls-client-auth-ca-ref'].nil? - end.to_return(headers: response_headers, body: registration.to_json) - - action.provision(account) - - expect(update).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). diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index 29f04cded0a..85d93f7b8cc 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -30,7 +30,7 @@ module VCAP::CloudController 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') } + let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } before do TestConfig.override(service_account_provisioning_enabled: true) diff --git a/spec/unit/jobs/v3/service_account_provision_spec.rb b/spec/unit/jobs/v3/service_account_provision_spec.rb index 16f580798a8..d6cbe29da2c 100644 --- a/spec/unit/jobs/v3/service_account_provision_spec.rb +++ b/spec/unit/jobs/v3/service_account_provision_spec.rb @@ -6,7 +6,7 @@ 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(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } let(:locator) { CloudController::DependencyLocator.instance } let(:job) { Jobs::V3.const_get(:ServiceAccountProvision).new(account.guid) } 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 84c241fdcbf..0794d2b2863 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 @@ -4,7 +4,7 @@ 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(:settings) { { client_id: 'account-manager', client_secret: 'manager-secret', identity_ca_ref: 'diego-instance-identity' } } let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space)) } let(:uaa_url) { 'https://uaa.service.cf.internal' } let(:headers) { { 'content-type' => 'application/json' } } @@ -24,79 +24,31 @@ module VCAP::CloudController expect { ConfigSchemas::ApiSchema.validate(api_config) }.to raise_error(Membrane::SchemaValidationError, /client_id => Missing key/) end - %i[client_id client_secret].each do |key| + %i[client_id client_secret identity_ca_ref].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 end - %i[client_id client_secret identity_ca].each do |key| + %i[client_id client_secret identity_ca_ref].each do |key| 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 - context 'with a UAA trust bundle reference instead of inline CA certificates' do - let(:settings) { { client_id: 'account-manager', client_secret: 'manager-secret', identity_ca_ref: 'diego-instance-identity' } } - - it 'accepts the reference in worker and API configuration' do - expect { ConfigSchemas::WorkerSchema.validate(worker_config) }.not_to raise_error - api_config = Config.read_file('config/cloud_controller.yml').merge(service_account_provisioning: settings) - expect { ConfigSchemas::ApiSchema.validate(api_config) }.not_to raise_error - end - - it 'registers account clients with the configured reference' do - WebMock::API.stub_request(:post, "#{uaa_url}/oauth/token"). - 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}").to_return(status: 404) - creation = WebMock::API.stub_request(:post, "#{uaa_url}/oauth/clients").with do |request| - payload = Oj.load(request.body) - payload['tls-client-auth-ca-ref'] == 'diego-instance-identity' && !payload.key?('tls-client-auth-ca') - end.to_return(status: 201, headers: headers, body: { client_id: account.client_id }.to_json) - - locator.service_account_provisioner.provision(account) - - expect(creation).to have_been_requested.once - end - - it 'updates an owned inline-CA client to the reference through the configured management client' do - WebMock::API.stub_request(:post, "#{uaa_url}/oauth/token"). - to_return(headers: headers, body: { access_token: 'manager-token', token_type: 'bearer', expires_in: 300 }.to_json) - existing = { - client_id: account.client_id, authorized_grant_types: ['client_credentials'], - authorities: %w[cloud_controller.read cloud_controller.write], access_token_validity: 300, - 'tls-client-auth-ca' => 'instance-identity-ca', tls_client_auth_san_dns: account.certificate_dns_san, - cf_service_account_guid: account.guid - } - WebMock::API.stub_request(:get, "#{uaa_url}/oauth/clients/#{account.client_id}"). - to_return(headers: headers, body: existing.to_json) - update = WebMock::API.stub_request(:put, "#{uaa_url}/oauth/clients/#{account.client_id}"). - with(headers: { 'Authorization' => 'bearer manager-token' }) do |request| - payload = Oj.load(request.body) - payload['tls-client-auth-ca-ref'] == 'diego-instance-identity' && - payload.key?('tls-client-auth-ca') && payload['tls-client-auth-ca'].nil? - end.to_return(headers: headers, body: existing.to_json) - - locator.service_account_provisioner.provision(account) - - expect(update).to have_been_requested.once - expect(account.reload.status).to eq('ready') - end - end - - it 'fails closed when both inline CA certificates and a bundle reference are configured' do - TestConfig.override(service_account_provisioning: settings.merge(identity_ca_ref: 'diego-instance-identity')) - expect { locator.service_account_provisioner }.to raise_error(/service account provisioning is not configured/) + it 'accepts the reference in API configuration for local workers' do + api_config = Config.read_file('config/cloud_controller.yml').merge(service_account_provisioning: settings) + expect { ConfigSchemas::ApiSchema.validate(api_config) }.not_to raise_error end - it 'fails closed when neither inline CA certificates nor a bundle reference are configured' do - TestConfig.override(service_account_provisioning: settings.except(:identity_ca)) + it 'fails closed when inline CA certificates are configured' do + TestConfig.override(service_account_provisioning: settings.merge(identity_ca: 'instance-identity-ca')) expect { locator.service_account_provisioner }.to raise_error(/service account provisioning is not configured/) end - it 'uses only the configured management credentials and instance identity CA' do + it 'uses only the configured management credentials and trust bundle reference' 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) @@ -106,7 +58,7 @@ module VCAP::CloudController with(headers: { 'Authorization' => 'bearer manager-token' }) do |request| payload = Oj.load(request.body) payload['client_id'] == account.client_id && - payload['tls-client-auth-ca'] == settings[:identity_ca] && + payload['tls-client-auth-ca-ref'] == settings[:identity_ca_ref] && !payload.key?('tls-client-auth-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) From 47c6bd57f111caf89132a34db1bc2d49653fc30a Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 16 Oct 2026 11:45:55 +0200 Subject: [PATCH 065/200] Require UAA trust bundle references for account clients and drop inline CA support --- app/actions/service_account_provision.rb | 40 ++++++------------- .../api_resources/_service_accounts.md | 16 ++++---- .../config_schemas/api_schema.rb | 3 +- .../config_schemas/worker_schema.rb | 3 +- lib/cloud_controller/dependency_locator.rb | 6 +-- .../uaa/service_account_client.rb | 6 --- ..._account_provisioner_configuration_spec.rb | 2 - 7 files changed, 24 insertions(+), 52 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index c2f80a2602b..c4ad8e6e125 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -2,15 +2,13 @@ module VCAP::CloudController class ServiceAccountProvision class Conflict < StandardError; end - TRUST_KEYS = %w[tls-client-auth-ca tls-client-auth-ca-ref].freeze - - def initialize(clients, identity_ca: nil, identity_ca_ref: nil) - trust = { 'tls-client-auth-ca' => identity_ca, 'tls-client-auth-ca-ref' => identity_ca_ref }.compact_blank - raise ArgumentError.new('exactly one of identity_ca or identity_ca_ref must be configured') unless trust.size == 1 + # identity_ca_ref names an operator-defined UAA trust bundle containing the instance identity CA. + # CA rotation changes the bundle's contents in UAA; registered clients never change. + def initialize(clients, identity_ca_ref:) + raise ArgumentError.new('identity_ca_ref must name a UAA trust bundle') if identity_ca_ref.blank? @clients = clients - # Inline PEM, or the name of an operator-defined UAA trust bundle (rotated in UAA without client updates). - @trust = trust + @identity_ca_ref = identity_ca_ref end def provision(account) @@ -26,13 +24,10 @@ def provision(account) desired = registration(account) existing = existing_client(account.client_id) - if existing.nil? + if existing + raise Conflict.new('client identity collision') unless matching_registration?(existing, desired) + else @clients.add(:client, desired) - elsif !matching_registration?(existing, desired) - raise Conflict.new('client identity collision') unless trust_anchor_drift_only?(existing, desired) - - # UAA merges client updates: clear the replaced trust setting explicitly. - @clients.put(:client, desired.merge((TRUST_KEYS - desired.keys).index_with(nil))) end User.create(guid: account.client_id, is_oauth_client: true, active: true) unless principal @@ -62,7 +57,7 @@ def deprovision(account, delete: false) existing = existing_client(account.client_id) if existing - raise Conflict.new('client identity collision') unless owned_client?(existing, registration(account)) + raise Conflict.new('client identity collision') unless matching_registration?(existing, registration(account)) @clients.delete(:client, account.client_id) end @@ -83,17 +78,6 @@ def deprovision(account, delete: false) private - # A client carrying this account's marker whose only difference is its trust anchor (inline CA - # bundle changed, or switched between inline CA and a UAA trust bundle reference) is ours to update. - # Every other policy difference, or a missing marker, remains a collision. - def trust_anchor_drift_only?(existing, desired) - matching_registration?(existing.except(*TRUST_KEYS), desired.except(*TRUST_KEYS)) - end - - def owned_client?(existing, desired) - matching_registration?(existing, desired) || trust_anchor_drift_only?(existing, desired) - end - 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 @@ -122,11 +106,11 @@ def registration(account) 'authorized_grant_types' => ['client_credentials'], 'authorities' => %w[cloud_controller.read cloud_controller.write], 'scope' => [], - 'access_token_validity' => 300 - }.merge(@trust).merge( + 'access_token_validity' => 300, + 'tls-client-auth-ca-ref' => @identity_ca_ref, 'tls_client_auth_san_dns' => account.certificate_dns_san, 'cf_service_account_guid' => account.guid - ) + } 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 f6a1b8ed2a7..b870201b981 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -89,10 +89,8 @@ 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. The one exception -is a client carrying the account's GUID marker whose only difference is its trust -anchor (changed inline CA bundle, or a switch between inline CA and a UAA trust -bundle reference): it is updated in place, clearing the replaced setting. Transient worker +Client collisions, including any registration difference, 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. @@ -141,11 +139,11 @@ BOSH properties under `cc.service_accounts`: - `token_endpoint`: HTTPS UAA mTLS endpoint without credentials/query/fragment. - Worker-only `management_client_id`, `management_client_secret`: dedicated UAA management credentials. Configure namespace protection independently in UAA. -- Worker-only trust, exactly one of: `identity_ca_ref`, the name of an operator-defined - UAA trust bundle (`uaa.mtls.trust_bundles`) containing the instance identity CA, - preferred because CA rotation is then a UAA configuration change; or `identity_ca`, - the instance identity CA PEM copied into every registration. Existing clients are - updated when next provisioned (first bind or enable); there is no background sweep. +- Worker-only `identity_ca_ref` (required): name of an operator-defined UAA trust + bundle (`uaa.mtls.trust_bundles`, UAA with named bundle support) containing the + instance identity CA. Clients reference the bundle; rotate the CA by changing the + bundle's contents in UAA (old+new, then new). Inline CA certificates are not + supported, and changing the configured bundle name makes existing clients collide. App-hosted local workers may receive equivalent `service_account_provisioning` configuration separately; the API BOSH template does not render management secrets. diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index 4eb755da65e..f9ab51841df 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -37,8 +37,7 @@ class ApiSchema < VCAP::Config optional(:service_account_provisioning) => { client_id: String, client_secret: String, - optional(:identity_ca) => String, - optional(:identity_ca_ref) => String + identity_ca_ref: String }, system_domain: String, diff --git a/lib/cloud_controller/config_schemas/worker_schema.rb b/lib/cloud_controller/config_schemas/worker_schema.rb index 086f25935dc..332c659f253 100644 --- a/lib/cloud_controller/config_schemas/worker_schema.rb +++ b/lib/cloud_controller/config_schemas/worker_schema.rb @@ -32,8 +32,7 @@ class WorkerSchema < VCAP::Config optional(:service_account_provisioning) => { client_id: String, client_secret: String, - optional(:identity_ca) => String, - optional(:identity_ca_ref) => String + identity_ca_ref: String }, optional(:service_account_runtime_enabled) => bool, optional(:service_account_token_endpoint) => String, diff --git a/lib/cloud_controller/dependency_locator.rb b/lib/cloud_controller/dependency_locator.rb index be823c7acbd..000553a5bbb 100644 --- a/lib/cloud_controller/dependency_locator.rb +++ b/lib/cloud_controller/dependency_locator.rb @@ -312,8 +312,8 @@ def service_account_provisioner settings = config.get(:service_account_provisioning) configured = ->(key) { settings[key].is_a?(String) && settings[key].present? } - unless settings.is_a?(Hash) && %i[client_id client_secret].all?(&configured) && - %i[identity_ca identity_ca_ref].one?(&configured) && %i[identity_ca identity_ca_ref].none? { |key| settings.key?(key) && !configured.call(key) } + # Inline CA certificates are not supported: clients reference a UAA trust bundle. + unless settings.is_a?(Hash) && %i[client_id client_secret identity_ca_ref].all?(&configured) && !settings.key?(:identity_ca) raise 'service account provisioning is not configured' end @@ -324,7 +324,7 @@ def service_account_provisioner ca_file: config.get(:uaa, :ca_file) ) register(:service_account_provisioner, - ServiceAccountProvision.new(clients, identity_ca: settings[:identity_ca], identity_ca_ref: settings[:identity_ca_ref])) + ServiceAccountProvision.new(clients, identity_ca_ref: settings[:identity_ca_ref])) 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 index b571808e6d5..81646f6cc0a 100644 --- a/lib/cloud_controller/uaa/service_account_client.rb +++ b/lib/cloud_controller/uaa/service_account_client.rb @@ -14,12 +14,6 @@ def add(type, registration) with_cache_retry { scim.add(type, registration) } end - def put(type, registration) - raise ArgumentError.new('only client resources are supported') unless type == :client - - with_cache_retry { scim.put(type, registration) } - end - def delete(type, id) raise ArgumentError.new('only client resources are supported') unless type == :client 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 0794d2b2863..ea4a77e7728 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 @@ -29,9 +29,7 @@ module VCAP::CloudController worker_config[:service_account_provisioning] = settings.except(key) expect { ConfigSchemas::WorkerSchema.validate(worker_config) }.to raise_error(Membrane::SchemaValidationError, /#{key} => Missing key/) end - end - %i[client_id client_secret identity_ca_ref].each do |key| 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/) From a4247e67a77eb787977fd2dbe5a8d0481b9f8fa1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 14:16:49 +0100 Subject: [PATCH 066/200] Test immutable organization service account prefix allocation (red) --- ...rganization_service_account_prefix_spec.rb | 148 ++++++++++++++++++ ...rganization_service_account_prefix_spec.rb | 47 ++++++ 2 files changed, 195 insertions(+) create mode 100644 spec/request/organization_service_account_prefix_spec.rb create mode 100644 spec/unit/actions/organization_service_account_prefix_spec.rb diff --git a/spec/request/organization_service_account_prefix_spec.rb b/spec/request/organization_service_account_prefix_spec.rb new file mode 100644 index 00000000000..5622073d820 --- /dev/null +++ b/spec/request/organization_service_account_prefix_spec.rb @@ -0,0 +1,148 @@ +require 'spec_helper' + +RSpec.describe 'Organization service account prefixes' do + let(:user) { create(:user) } + let(:org) { create(:organization) } + let(:admin_header) { admin_headers_for(user) } + + def create_org(name, prefix) + post '/v3/organizations', { name: name, service_account_prefix: prefix }.to_json, admin_header + end + + def allocate_prefix(prefix, auth = admin_header) + patch "/v3/organizations/#{org.guid}", { service_account_prefix: prefix }.to_json, auth + end + + it 'allows an admin to allocate a prefix at creation and records it in the audit event' do + create_org('Payments', 'acme-payments') + + expect(last_response.status).to eq(201) + expect(parsed_response['service_account_prefix']).to eq('acme-payments') + created_guid = parsed_response['guid'] + event = VCAP::CloudController::Event.first(type: 'audit.organization.create', actee: created_guid) + expect(event.metadata.dig('request', 'service_account_prefix')).to eq('acme-payments') + + get "/v3/organizations/#{created_guid}", nil, admin_header + expect(parsed_response['service_account_prefix']).to eq('acme-payments') + get '/v3/organizations', nil, admin_header + expect(parsed_response['resources'].find { |resource| resource['guid'] == created_guid }['service_account_prefix']).to eq('acme-payments') + end + + it 'leaves ordinary org creation available without generating a prefix' do + post '/v3/organizations', { name: 'Ordinary Org' }.to_json, admin_header + + expect(last_response.status).to eq(201) + expect(parsed_response).to include('service_account_prefix' => nil) + end + + it 'accepts an explicitly unset prefix on creation' do + create_org('Ordinary Org', nil) + + expect(last_response.status).to eq(201) + expect(parsed_response).to include('service_account_prefix' => nil) + end + + it 'allocates once on an existing org and preserves the prefix across rename and metadata updates' do + allocate_prefix('acme-payments') + expect(last_response.status).to eq(200) + expect(parsed_response['service_account_prefix']).to eq('acme-payments') + event = VCAP::CloudController::Event.first(type: 'audit.organization.update', actee: org.guid) + expect(event.metadata.dig('request', 'service_account_prefix')).to eq('acme-payments') + + allocate_prefix('acme-payments') + expect(last_response.status).to eq(200) + patch "/v3/organizations/#{org.guid}", { name: 'Renamed', metadata: { labels: { team: 'payments' } } }.to_json, admin_header + expect(last_response.status).to eq(200) + expect(parsed_response).to include('name' => 'Renamed', 'service_account_prefix' => 'acme-payments') + end + + ['different-prefix', nil].each do |replacement| + it "rejects changing an allocated prefix to #{replacement.inspect} atomically" do + allocate_prefix('acme-payments') + expect(last_response.status).to eq(200) + + patch "/v3/organizations/#{org.guid}", { name: 'Must not change', service_account_prefix: replacement }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to include('immutable') + expect(org.reload.name).not_to eq('Must not change') + get "/v3/organizations/#{org.guid}", nil, admin_header + expect(parsed_response['service_account_prefix']).to eq('acme-payments') + end + end + + it 'rejects a prefix already allocated to another org on creation or update' do + create_org('First Org', 'acme-payments') + expect(last_response.status).to eq(201) + + expect { create_org('Second Org', 'acme-payments') }.not_to change(VCAP::CloudController::Organization, :count) + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to include('already reserved') + allocate_prefix('acme-payments') + expect(last_response.status).to eq(422) + allocate_prefix('available-prefix') + expect(last_response.status).to eq(200) + end + + it 'retains a prefix reservation after org deletion' do + allocate_prefix('acme-payments') + expect(last_response.status).to eq(200) + delete "/v3/organizations/#{org.guid}", nil, admin_header + expect(last_response.status).to eq(202) + execute_all_jobs(expected_successes: 2, expected_failures: 0) + expect(VCAP::CloudController::Organization.first(guid: org.guid)).to be_nil + + create_org('Replacement Org', 'acme-payments') + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to include('already reserved') + end + + ['abc', 'a-1', '123', 'a' * 63].each do |prefix| + it "accepts the RFC label #{prefix.inspect}" do + create_org('Valid Org', prefix) + + expect(last_response.status).to eq(201) + expect(parsed_response['service_account_prefix']).to eq(prefix) + end + end + + ['', 'ab', 'a' * 64, 'Upper', '-abc', 'abc-', 'a.b', 'a_b', 'abc def', "abc\n", 'café', 123, false, {}].each do |prefix| + it "rejects the invalid prefix #{prefix.inspect} on create and update" do + create_org('Invalid Org', prefix) + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to match(/Service account prefix/i) + + allocate_prefix(prefix) + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to match(/Service account prefix/i) + end + end + + it 'does not let an org manager allocate a prefix or combine it with another update' do + org.add_user(user) + org.add_manager(user) + auth = headers_for(user) + + patch "/v3/organizations/#{org.guid}", { name: 'Must not change', service_account_prefix: 'acme-payments' }.to_json, auth + + expect(last_response.status).to eq(403) + expect(org.reload.name).not_to eq('Must not change') + allocate_prefix('acme-payments') + expect(last_response.status).to eq(200) + allocate_prefix('acme-payments', auth) + expect(last_response.status).to eq(403) + end + + it 'does not let self-service org creators allocate a prefix' do + create(:feature_flag, name: 'user_org_creation', enabled: true) + + expect do + post '/v3/organizations', { name: 'Self Service', service_account_prefix: 'acme-payments' }.to_json, headers_for(user) + end.not_to change(VCAP::CloudController::Organization, :count) + expect(last_response.status).to eq(403) + + post '/v3/organizations', { name: 'Self Service' }.to_json, headers_for(user) + expect(last_response.status).to eq(201) + expect(parsed_response).to include('service_account_prefix' => nil) + end +end diff --git a/spec/unit/actions/organization_service_account_prefix_spec.rb b/spec/unit/actions/organization_service_account_prefix_spec.rb new file mode 100644 index 00000000000..b4ccc624430 --- /dev/null +++ b/spec/unit/actions/organization_service_account_prefix_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' +require 'actions/organization_create' +require 'actions/organization_update' + +module VCAP::CloudController + RSpec.describe 'Atomic organization service account prefix allocation' do + let(:user_audit_info) { UserAuditInfo.new(user_guid: create(:user).guid, user_email: 'admin@example.com') } + + it 'allows exactly one concurrent creator to reserve a prefix', isolation: :truncation do + audit_info = user_audit_info + gate = Queue.new + threads = 2.times.map do |index| + Thread.new do + gate.pop + message = OrganizationCreateMessage.new(name: "org-#{index}", service_account_prefix: 'shared-prefix') + OrganizationCreate.new(user_audit_info: audit_info).create(message) + :created + rescue OrganizationCreate::Error => e + e.message + end + end + threads.size.times { gate << true } + + expect(threads.map(&:value)).to contain_exactly(:created, "Service account prefix 'shared-prefix' is already reserved.") + expect(Organization.count).to eq(1) + end + + it 'serializes competing allocations on the same initially unset org', isolation: :truncation do + org = create(:organization) + audit_info = user_audit_info + gate = Queue.new + threads = %w[first-prefix second-prefix].map do |prefix| + Thread.new do + stale_org = Organization.first(guid: org.guid) + gate.pop + OrganizationUpdate.new(audit_info).update(stale_org, OrganizationUpdateMessage.new(service_account_prefix: prefix)) + :updated + rescue OrganizationUpdate::Error => e + e.message + end + end + threads.size.times { gate << true } + + expect(threads.map(&:value)).to contain_exactly(:updated, 'Service account prefix is immutable once allocated.') + end + end +end From d54e07506283ac6d29cc38c94bdb20db3c87f40f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 14:25:56 +0100 Subject: [PATCH 067/200] Allocate immutable organization service account prefixes --- app/actions/organization_create.rb | 2 ++ app/actions/organization_update.rb | 4 ++++ app/controllers/v3/organizations_controller.rb | 2 ++ app/messages/organization_update_message.rb | 8 +++++++- app/models/runtime/organization.rb | 12 ++++++++++++ app/presenters/v3/organization_presenter.rb | 1 + ..._add_organization_service_account_prefixes.rb | 16 ++++++++++++++++ .../includes/api_resources/_organizations.erb | 5 +++++ .../resources/organizations/_create.md.erb | 1 + .../resources/organizations/_object.md.erb | 1 + .../resources/organizations/_update.md.erb | 3 ++- .../organization_service_account_prefix_spec.rb | 11 ++++++++--- spec/request/organizations_spec.rb | 11 +++++++++++ .../organization_service_account_prefix_spec.rb | 2 +- 14 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 db/migrations/20261109120000_add_organization_service_account_prefixes.rb diff --git a/app/actions/organization_create.rb b/app/actions/organization_create.rb index 0c0736610ef..d3db1e9518f 100644 --- a/app/actions/organization_create.rb +++ b/app/actions/organization_create.rb @@ -14,6 +14,7 @@ def create(message) Organization.db.transaction do org = VCAP::CloudController::Organization.create( name: message.name, + service_account_prefix: message.service_account_prefix, status: message.suspended ? Organization::SUSPENDED : Organization::ACTIVE ) @@ -29,6 +30,7 @@ def create(message) private def validation_error!(error, message) + error!("Service account prefix '#{message.service_account_prefix}' is already reserved.") if error.errors.on(:service_account_prefix)&.include?(:unique) error!("Organization '#{message.name}' already exists.") if error.errors.on(:name)&.include?(:unique) error!(error.message) end diff --git a/app/actions/organization_update.rb b/app/actions/organization_update.rb index 2c814cde6bd..30a1892eba3 100644 --- a/app/actions/organization_update.rb +++ b/app/actions/organization_update.rb @@ -10,6 +10,7 @@ def initialize(user_audit_info) def update(org, message) org.db.transaction do org.lock! + org.service_account_prefix = message.service_account_prefix if message.requested?(:service_account_prefix) org.name = message.name if message.requested?(:name) LabelsUpdate.update(org, message.labels, OrganizationLabelModel) AnnotationsUpdate.update(org, message.annotations, OrganizationAnnotationModel) @@ -29,6 +30,9 @@ def update(org, message) end def validation_error!(error, message) + prefix_errors = error.errors.on(:service_account_prefix) + error!("Service account prefix '#{message.service_account_prefix}' is already reserved.") if prefix_errors&.include?(:unique) + error!('Service account prefix is immutable once allocated.') if prefix_errors&.include?(:immutable) error!("Organization name '#{message.name}' is already taken.") if error.errors.on(:name)&.include?(:unique) error!(error.message) end diff --git a/app/controllers/v3/organizations_controller.rb b/app/controllers/v3/organizations_controller.rb index 162ea64ab6e..652a0f5d236 100644 --- a/app/controllers/v3/organizations_controller.rb +++ b/app/controllers/v3/organizations_controller.rb @@ -52,6 +52,7 @@ def create unprocessable!(message.errors.full_messages) unless message.valid? unauthorized! if suspended_by_non_admin?(message) + unauthorized! if message.requested?(:service_account_prefix) && !permission_queryer.can_write_globally? org = nil @@ -82,6 +83,7 @@ def update unprocessable!(message.errors.full_messages) unless message.valid? unauthorized! if suspended_by_non_admin?(message) + unauthorized! if message.requested?(:service_account_prefix) && !permission_queryer.can_write_globally? org = OrganizationUpdate.new(user_audit_info).update(org, message) diff --git a/app/messages/organization_update_message.rb b/app/messages/organization_update_message.rb index 26e82b4b0f2..b79f76726e2 100644 --- a/app/messages/organization_update_message.rb +++ b/app/messages/organization_update_message.rb @@ -2,7 +2,7 @@ module VCAP::CloudController class OrganizationUpdateMessage < MetadataBaseMessage - register_allowed_keys %i[name suspended] + register_allowed_keys %i[name suspended service_account_prefix] validates_with NoAdditionalKeysValidator @@ -15,5 +15,11 @@ class OrganizationUpdateMessage < MetadataBaseMessage validates :suspended, boolean: true, allow_nil: true + + validates :service_account_prefix, + string: true, + format: { with: ->(_) { Organization::SERVICE_ACCOUNT_PREFIX_REGEX }, + message: 'must be a lowercase RFC 1123 label of 3-63 characters' }, + allow_nil: true end end diff --git a/app/models/runtime/organization.rb b/app/models/runtime/organization.rb index 88111564116..4fc96c3b64e 100644 --- a/app/models/runtime/organization.rb +++ b/app/models/runtime/organization.rb @@ -7,6 +7,7 @@ class Organization < Sequel::Model ORG_NAME_REGEX = /\A[[:alnum:][:punct:][:print:]]+\Z/ ORG_STATUS_VALUES = VALID_STATUSES + SERVICE_ACCOUNT_PREFIX_REGEX = /\A[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\z/ one_to_many :spaces @@ -205,12 +206,21 @@ def before_save validate_quota + # Unique insertion serializes allocation; reservations survive org deletion. + # rubocop:disable Rails/SkipsModelValidations -- Atomic Sequel dataset reservation, not an ActiveRecord model. + db[:service_account_prefixes].insert(prefix: service_account_prefix) if service_account_prefix && (new? || column_changed?(:service_account_prefix)) + # rubocop:enable Rails/SkipsModelValidations + super end def around_save yield rescue Sequel::UniqueConstraintViolation => e + if e.message.include?('service_account_prefixes_prefix_unique') || e.message.include?('organizations_service_account_prefix_unique') + errors.add(:service_account_prefix, :unique) + raise validation_failed_error + end raise e unless e.message.include?('organizations_name_index') errors.add(:name, :unique) @@ -221,6 +231,8 @@ def validate validates_presence :name validates_format ORG_NAME_REGEX, :name validates_includes ORG_STATUS_VALUES, :status, allow_missing: true + validates_format SERVICE_ACCOUNT_PREFIX_REGEX, :service_account_prefix, allow_nil: true + errors.add(:service_account_prefix, :immutable) if !new? && column_changed?(:service_account_prefix) && initial_value(:service_account_prefix) return unless column_changed?(:default_isolation_segment_guid) diff --git a/app/presenters/v3/organization_presenter.rb b/app/presenters/v3/organization_presenter.rb index 65ce25b12ae..fa7ded1c36d 100644 --- a/app/presenters/v3/organization_presenter.rb +++ b/app/presenters/v3/organization_presenter.rb @@ -18,6 +18,7 @@ def to_hash created_at: organization.created_at, updated_at: organization.updated_at, name: organization.name, + service_account_prefix: organization.service_account_prefix, suspended: organization.suspended?, relationships: { quota: { diff --git a/db/migrations/20261109120000_add_organization_service_account_prefixes.rb b/db/migrations/20261109120000_add_organization_service_account_prefixes.rb new file mode 100644 index 00000000000..23caadd9026 --- /dev/null +++ b/db/migrations/20261109120000_add_organization_service_account_prefixes.rb @@ -0,0 +1,16 @@ +Sequel.migration do + change do + # Reservations deliberately outlive their organizations. + create_table :service_account_prefixes do + primary_key :id, name: :service_account_prefixes_pkey + String :prefix, size: 63, null: false + index :prefix, unique: true, name: :service_account_prefixes_prefix_unique + end + + alter_table :organizations do + add_column :service_account_prefix, String, size: 63 + # The new column is null for all existing organizations. + add_index :service_account_prefix, unique: true, name: :organizations_service_account_prefix_unique, concurrently: false + end + end +end diff --git a/docs/v3/source/includes/api_resources/_organizations.erb b/docs/v3/source/includes/api_resources/_organizations.erb index e2664fb7a4d..6105b4be137 100644 --- a/docs/v3/source/includes/api_resources/_organizations.erb +++ b/docs/v3/source/includes/api_resources/_organizations.erb @@ -4,6 +4,7 @@ "created_at": "2017-02-01T01:33:58Z", "updated_at": "2017-02-01T01:33:58Z", "name": "my-organization", + "service_account_prefix": null, "suspended": false, "relationships": { "quota": { @@ -53,6 +54,7 @@ "created_at": "2017-02-01T01:33:58Z", "updated_at": "2017-02-01T01:33:58Z", "name": "org1", + "service_account_prefix": null, "suspended": false, "relationships": { "quota": { @@ -85,6 +87,7 @@ "created_at": "2017-02-02T00:14:30Z", "updated_at": "2017-02-02T00:14:30Z", "name": "org2", + "service_account_prefix": null, "suspended": false, "relationships": { "quota": { @@ -136,6 +139,7 @@ "created_at": "2017-02-01T01:33:58Z", "updated_at": "2017-02-01T01:33:58Z", "name": "org1", + "service_account_prefix": null, "suspended": false, "links": { "self": { @@ -168,6 +172,7 @@ "created_at": "2017-02-02T00:14:30Z", "updated_at": "2017-02-02T00:14:30Z", "name": "org2", + "service_account_prefix": null, "suspended": false, "relationships": { "quota": { diff --git a/docs/v3/source/includes/resources/organizations/_create.md.erb b/docs/v3/source/includes/resources/organizations/_create.md.erb index ecfd933f6b3..6ebff90fcab 100644 --- a/docs/v3/source/includes/resources/organizations/_create.md.erb +++ b/docs/v3/source/includes/resources/organizations/_create.md.erb @@ -38,6 +38,7 @@ Name | Type | Description Name | Type | Description ---- | ---- | ----------- **suspended** | _boolean_ | Whether an organization is suspended or not +**service_account_prefix** | _string_ or _null_ | Admin only; reserve a foundation-unique service-account namespace. Must be a lowercase RFC 1123 label of 3–63 characters, beginning and ending with a letter or digit. Defaults to `null`; no prefix is generated. Once allocated, the prefix cannot be renamed, cleared or reassigned, and remains reserved after org deletion. **metadata.labels** | [_label object_](#labels) | Labels applied to the organization **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the organization diff --git a/docs/v3/source/includes/resources/organizations/_object.md.erb b/docs/v3/source/includes/resources/organizations/_object.md.erb index 45defad851c..e54406763f6 100644 --- a/docs/v3/source/includes/resources/organizations/_object.md.erb +++ b/docs/v3/source/includes/resources/organizations/_object.md.erb @@ -13,6 +13,7 @@ Name | Type | Description **created_at** | _[timestamp](#timestamps)_ | The time with zone when the object was created **updated_at** | _[timestamp](#timestamps)_ | The time with zone when the object was last updated **name** | _string_ | Name of the organization +**service_account_prefix** | _string_ or _null_ | Immutable service-account namespace, unique within the foundation and allocated by an Admin. Independent of the organization name and GUID; `null` until allocated. Reservations survive organization deletion. **suspended** | _boolean_ | Whether an organization is suspended or not; non-admins will be blocked from creating, updating, or deleting resources in a suspended organization **relationships.quota** | [_to-one relationship_](#to-one-relationships) | The quota applied to the organization **metadata.labels** | [_label object_](#labels) | Labels applied to the organization diff --git a/docs/v3/source/includes/resources/organizations/_update.md.erb b/docs/v3/source/includes/resources/organizations/_update.md.erb index 9e6f08cf059..acdfe3b95f3 100644 --- a/docs/v3/source/includes/resources/organizations/_update.md.erb +++ b/docs/v3/source/includes/resources/organizations/_update.md.erb @@ -33,6 +33,7 @@ Name | Type | Description ---- | ---- | ----------- **name** | _string_ | Organization name | **suspended** | _boolean_ | Whether an organization is suspended or not +**service_account_prefix** | _string_ or _null_ | Admin only; allocate a prefix when currently unset. Must be an available lowercase RFC 1123 label of 3–63 characters with alphanumeric boundaries. Repeating the current value succeeds; changing or clearing an allocated prefix fails. Org rename does not change it; org deletion does not release it. **metadata.labels** | [_label object_](#labels) | Labels applied to the organization **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the organization @@ -41,4 +42,4 @@ Name | Type | Description Role | Notes ---- | ----- Admin | -Org Manager | Cannot change the `suspended` field +Org Manager | Cannot change the `suspended` field or supply `service_account_prefix` diff --git a/spec/request/organization_service_account_prefix_spec.rb b/spec/request/organization_service_account_prefix_spec.rb index 5622073d820..132a877e34a 100644 --- a/spec/request/organization_service_account_prefix_spec.rb +++ b/spec/request/organization_service_account_prefix_spec.rb @@ -9,7 +9,7 @@ def create_org(name, prefix) post '/v3/organizations', { name: name, service_account_prefix: prefix }.to_json, admin_header end - def allocate_prefix(prefix, auth = admin_header) + def allocate_prefix(prefix, auth=admin_header) patch "/v3/organizations/#{org.guid}", { service_account_prefix: prefix }.to_json, auth end @@ -87,13 +87,15 @@ def allocate_prefix(prefix, auth = admin_header) it 'retains a prefix reservation after org deletion' do allocate_prefix('acme-payments') expect(last_response.status).to eq(200) + expect(VCAP::CloudController::Organization.db[:service_account_prefixes].where(prefix: 'acme-payments').count).to eq(1) delete "/v3/organizations/#{org.guid}", nil, admin_header expect(last_response.status).to eq(202) - execute_all_jobs(expected_successes: 2, expected_failures: 0) + execute_all_jobs(expected_successes: 1, expected_failures: 0) expect(VCAP::CloudController::Organization.first(guid: org.guid)).to be_nil + expect(VCAP::CloudController::Organization.db[:service_account_prefixes].where(prefix: 'acme-payments').count).to eq(1) create_org('Replacement Org', 'acme-payments') - expect(last_response.status).to eq(422) + expect(last_response.status).to eq(422), last_response.body expect(parsed_response.dig('errors', 0, 'detail')).to include('already reserved') end @@ -135,6 +137,9 @@ def allocate_prefix(prefix, auth = admin_header) it 'does not let self-service org creators allocate a prefix' do create(:feature_flag, name: 'user_org_creation', enabled: true) + uaa_client = instance_double(VCAP::CloudController::UaaClient) + allow(CloudController::DependencyLocator.instance).to receive(:uaa_username_lookup_client).and_return(uaa_client) + allow(uaa_client).to receive(:usernames_for_ids).with([user.guid]).and_return({ user.guid => 'creator' }) expect do post '/v3/organizations', { name: 'Self Service', service_account_prefix: 'acme-payments' }.to_json, headers_for(user) diff --git a/spec/request/organizations_spec.rb b/spec/request/organizations_spec.rb index 0c914aafa5a..ff63c5402af 100644 --- a/spec/request/organizations_spec.rb +++ b/spec/request/organizations_spec.rb @@ -68,6 +68,7 @@ module VCAP::CloudController 'labels' => { 'freaky' => 'friday' }, 'annotations' => { 'make' => 'subaru', 'model' => 'xv crosstrek', 'color' => 'orange' } }, + 'service_account_prefix' => nil, 'suspended' => false } ) @@ -98,6 +99,7 @@ module VCAP::CloudController }, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'relationships' => { 'quota' => { 'data' => { 'guid' => created_org.quota_definition.guid } } }, + 'service_account_prefix' => nil, 'suspended' => true } ) @@ -133,6 +135,7 @@ module VCAP::CloudController 'labels' => { 'freaky' => 'friday' }, 'annotations' => { 'make' => 'subaru', 'model' => 'xv crosstrek', 'color' => 'orange' } }, + 'service_account_prefix' => nil, 'suspended' => false } ) @@ -284,6 +287,7 @@ module VCAP::CloudController 'labels' => {}, 'annotations' => {} }, + 'service_account_prefix' => nil, 'suspended' => false }, { @@ -304,6 +308,7 @@ module VCAP::CloudController 'labels' => {}, 'annotations' => {} }, + 'service_account_prefix' => nil, 'suspended' => false } ] @@ -409,6 +414,7 @@ module VCAP::CloudController 'labels' => {}, 'annotations' => {} }, + 'service_account_prefix' => nil, 'suspended' => false }, { @@ -429,6 +435,7 @@ module VCAP::CloudController 'labels' => {}, 'annotations' => {} }, + 'service_account_prefix' => nil, 'suspended' => false } ] @@ -1157,6 +1164,7 @@ module VCAP::CloudController 'labels' => {}, 'annotations' => {} }, + 'service_account_prefix' => nil, 'suspended' => false } end @@ -1216,6 +1224,7 @@ module VCAP::CloudController 'labels' => { 'freaky' => 'thursday' }, 'annotations' => { 'quality' => 'p sus' } }, + 'service_account_prefix' => nil, 'suspended' => false } @@ -1266,6 +1275,7 @@ module VCAP::CloudController 'created_at' => iso8601, 'updated_at' => iso8601, 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'service_account_prefix' => nil, 'suspended' => true } @@ -1311,6 +1321,7 @@ module VCAP::CloudController 'labels' => { 'animal' => 'horse' }, 'annotations' => {} }, + 'service_account_prefix' => nil, 'suspended' => false } diff --git a/spec/unit/actions/organization_service_account_prefix_spec.rb b/spec/unit/actions/organization_service_account_prefix_spec.rb index b4ccc624430..3c4aea24168 100644 --- a/spec/unit/actions/organization_service_account_prefix_spec.rb +++ b/spec/unit/actions/organization_service_account_prefix_spec.rb @@ -22,7 +22,7 @@ module VCAP::CloudController threads.size.times { gate << true } expect(threads.map(&:value)).to contain_exactly(:created, "Service account prefix 'shared-prefix' is already reserved.") - expect(Organization.count).to eq(1) + expect(Organization.where(name: %w[org-0 org-1]).count).to eq(1) end it 'serializes competing allocations on the same initially unset org', isolation: :truncation do From 829988505f73fc0716142a91337a4d2d604de520 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 14:39:18 +0100 Subject: [PATCH 068/200] Test account organization ownership and initial space entitlement (red) --- ...ice_account_organization_ownership_spec.rb | 28 +++++++++++ .../runtime/service_account_ownership_spec.rb | 49 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb create mode 100644 spec/unit/models/runtime/service_account_ownership_spec.rb diff --git a/spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb b/spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb new file mode 100644 index 00000000000..cc8c7934441 --- /dev/null +++ b/spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' +require 'migrations/helpers/migration_shared_context' + +RSpec.describe 'Service account organization ownership migration', isolation: :truncation, type: :migration do + include_context 'migration' do + let(:migration_filename) { '20261109121000_add_service_account_organization_ownership.rb' } + end + + it 'backfills the owning org and initial entitlement without changing legacy identities or assigning prefixes' do + space = create(:space) + guid = SecureRandom.uuid + now = Time.now.utc + db[:service_account_names].insert(name: 'legacy-worker') + db[:service_accounts].insert(guid: guid, name: 'legacy-worker', space_guid: space.guid, created_at: now, updated_at: now) + + Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) + + account = db[:service_accounts].first(guid: guid) + expect(account).to include(organization_id: space.organization_id, name: 'legacy-worker', space_guid: space.guid) + expect(db[:service_account_space_entitlements].where(service_account_guid: guid, space_guid: space.guid).count).to eq(1) + expect(db[:organizations].first(id: space.organization_id)[:service_account_prefix]).to be_nil + expect(db[:service_account_names].where(name: 'legacy-worker').count).to eq(1) + + Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) + expect(db[:service_accounts].first(guid: guid)).to include(name: 'legacy-worker', space_guid: space.guid) + expect(db.table_exists?(:service_account_space_entitlements)).to be(false) + end +end diff --git a/spec/unit/models/runtime/service_account_ownership_spec.rb b/spec/unit/models/runtime/service_account_ownership_spec.rb new file mode 100644 index 00000000000..69acbda54c5 --- /dev/null +++ b/spec/unit/models/runtime/service_account_ownership_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Service account organization ownership' do + let(:space) { create(:space) } + + it 'persists the immutable org owner and initial space entitlement at creation' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space) + + expect(account.reload.organization).to eq(space.organization) + expect(account.entitled_spaces).to contain_exactly(space) + expect(account.client_id).to eq('cf:service-account:payments-worker') + expect(account.certificate_dns_san).to eq('payments-worker.svc.identity') + end + + it 'does not duplicate the initial entitlement when metadata or state changes' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space) + account.update(description: 'Batch processing', enabled: false) + + expect(account.reload.entitled_spaces).to contain_exactly(space) + end + + it 'rejects changing the owning organization' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space) + other_org = create(:organization) + + expect { account.update(organization: other_org) }.to raise_error(Sequel::ValidationFailed, /organization.*immutable/) + expect(account.reload.organization).to eq(space.organization) + end + + it 'rejects creating an account whose initial space belongs to another org' do + other_org = create(:organization) + + expect do + ServiceAccountModel.create(name: 'payments-worker', space: space, organization: other_org) + end.to raise_error(Sequel::ValidationFailed, /organization/) + expect(ServiceAccountModel.count).to eq(0) + end + + it 'removes entitlement rows when the account is deleted without deleting the space or org' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space) + account.destroy + + expect(ServiceAccountModel.db[:service_account_space_entitlements].where(service_account_guid: account.guid).count).to eq(0) + expect(Space.first(guid: space.guid)).not_to be_nil + expect(Organization.first(id: space.organization_id)).not_to be_nil + end + end +end From c7de546fd985bce6e7ed7c4e4dc03a41708d7a1c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 14:44:11 +0100 Subject: [PATCH 069/200] Persist account organization owners and initial space entitlements --- app/models/runtime/service_account_model.rb | 19 ++++++++ ..._service_account_organization_ownership.rb | 43 +++++++++++++++++++ .../api_resources/_service_accounts.md | 19 ++++++++ 3 files changed, 81 insertions(+) create mode 100644 db/migrations/20261109121000_add_service_account_organization_ownership.rb diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 59054afd2d2..3640ad12b63 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -3,14 +3,28 @@ 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 + many_to_one :organization + many_to_many :entitled_spaces, + class: 'VCAP::CloudController::Space', + join_table: :service_account_space_entitlements, + left_key: :service_account_guid, left_primary_key: :guid, + right_key: :space_guid, right_primary_key: :guid 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 before_validation + self.organization = space.organization if new? && organization_id.nil? && space + super + end + def validate super validates_presence :space + validates_presence :organization + errors.add(:organization, 'must own the initial space') if new? && space && organization_id != space.organization_id + errors.add(:organization, 'is immutable') if !new? && column_changed?(:organization_id) 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) @@ -28,6 +42,11 @@ def around_create raise validation_failed_error end + def after_create + super + add_entitled_space(space) + end + def client_id "cf:service-account:#{name}" end diff --git a/db/migrations/20261109121000_add_service_account_organization_ownership.rb b/db/migrations/20261109121000_add_service_account_organization_ownership.rb new file mode 100644 index 00000000000..9201ea0b1ea --- /dev/null +++ b/db/migrations/20261109121000_add_service_account_organization_ownership.rb @@ -0,0 +1,43 @@ +Sequel.migration do + up do + organization_id_type = schema(:organizations).to_h.fetch(:id).fetch(:db_type) + alter_table :service_accounts do + add_column :organization_id, organization_id_type + add_foreign_key [:organization_id], :organizations, name: :fk_service_accounts_organization + end + + account_guid_options = {} + space_guid_options = {} + if database_type == :mysql + account_column = fetch("SHOW FULL COLUMNS FROM service_accounts LIKE 'guid'").first + space_column = fetch("SHOW FULL COLUMNS FROM spaces LIKE 'guid'").first + account_guid_options[:collate] = account_column[:Collation] || account_column[:collation] + space_guid_options[:collate] = space_column[:Collation] || space_column[:collation] + end + + create_table :service_account_space_entitlements do + String :service_account_guid, **account_guid_options, size: 255, null: false + String :space_guid, **space_guid_options, size: 255, null: false + primary_key %i[service_account_guid space_guid], name: :service_account_space_entitlements_pkey + foreign_key [:service_account_guid], :service_accounts, key: :guid, name: :fk_sa_entitlements_account, on_delete: :cascade + foreign_key [:space_guid], :spaces, key: :guid, name: :fk_sa_entitlements_space, on_delete: :cascade + end + + # Preserve UUIDs, names, authentication state and launch references. The + # original space is retained for old readers until the entitlement cutover. + from(:service_accounts).each do |account| + organization_id = from(:spaces).where(guid: account[:space_guid]).get(:organization_id) + from(:service_accounts).where(id: account[:id]).update(organization_id: organization_id) + from(:service_account_space_entitlements).insert(service_account_guid: account[:guid], space_guid: account[:space_guid]) + end + alter_table(:service_accounts) { set_column_not_null :organization_id } + end + + down do + drop_table :service_account_space_entitlements + alter_table :service_accounts do + drop_foreign_key [:organization_id], name: :fk_service_accounts_organization + drop_column :organization_id + 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 b870201b981..44dd64e2866 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -1,5 +1,24 @@ # Service accounts (experimental) +## Org-ownership transition + +The registry now persists an immutable owning organization and creates an initial +space-entitlement row atomically with every account. Migration +`20261109121000` backfills the organization from the existing space and entitles +that space. UUIDs, names, client IDs, SANs, state and workload references are +preserved; org prefixes are not generated or allocated by migration. + +This is the additive persistence step toward the org-owned RFC. The legacy space +column, API representation, authorization and space-deletion behavior described +below remain until their entitlement-aware cutover. Further entitlements are not +exposed by this step. Do not enable cross-space use with this intermediate version. +The required organization column means older CAPI writers cannot create accounts +after migration; coordinate API/worker upgrades or keep account creation disabled +during the transition. Downgrade removes the new owner/entitlement data while +retaining the original account records and space relationships. + +## Current API compatibility behavior + 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. From 658177301b6fc2ff0a379525c27ee8d9703a4088 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 14:49:49 +0100 Subject: [PATCH 070/200] Test retaining org accounts across space deletion (red) --- .../service_account_org_lifecycle_spec.rb | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 spec/unit/actions/service_account_org_lifecycle_spec.rb diff --git a/spec/unit/actions/service_account_org_lifecycle_spec.rb b/spec/unit/actions/service_account_org_lifecycle_spec.rb new file mode 100644 index 00000000000..dc356345886 --- /dev/null +++ b/spec/unit/actions/service_account_org_lifecycle_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' +require 'actions/organization_delete' + +module VCAP::CloudController + RSpec.describe 'Org-owned service account lifecycle' do + let(:space) { create(:space) } + let(:org) { space.organization } + let(:account) { ServiceAccountModel.create(name: 'shared-worker', space: space) } + let(:actor) { UserAuditInfo.new(user_guid: create(:user).guid, user_email: 'manager@example.com') } + let(:space_delete) { SpaceDelete.new(actor, Repositories::ServiceEventRepository.new(actor)) } + let(:org_delete) { OrganizationDelete.new(space_delete, actor) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } + + before do + TestConfig.override(kubernetes: {}) + 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 'keeps the enabled account, principal and grants when its last space is deleted' do + provisioner.provision(account) + principal = User.first(guid: account.client_id) + org.add_user(principal) + app = create(:app_model, space: space, service_account: account) + + expect(space_delete.delete([space])).to be_empty + + expect(account.reload.organization).to eq(org) + expect(account.enabled).to be(true) + expect(account.status).to eq('ready') + expect(account.entitled_spaces).to be_empty + expect(account.space_guid).to be_nil + expect(User.first(guid: principal.guid)).not_to be_nil + expect(org.users_dataset.where(id: principal.id)).to exist + expect(AppModel.first(guid: app.guid)).to be_nil + expect(clients).not_to have_received(:delete) + end + + it 'cleans up a zero-entitlement account on org deletion, retaining the parent after failure for retry' do + account + expect(space_delete.delete([space])).to be_empty + allow(clients).to receive(:get).and_raise('UAA unavailable') + + expect(org_delete.delete([org]).map(&:message).join).to include('UAA unavailable') + expect(Organization.first(id: org.id)).not_to be_nil + expect(account.reload.status).to eq('failed') + + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + expect(org_delete.delete([org])).to be_empty + expect(ServiceAccountModel.first(guid: account.guid)).to be_nil + expect(Organization.first(id: org.id)).to be_nil + end + end +end From 8d68274ebd78734b15ade62a521c21eb7eb18e58 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 14:53:18 +0100 Subject: [PATCH 071/200] Retain service accounts on space deletion and clean up with their org --- app/actions/organization_delete.rb | 24 ++++++++++++ app/actions/space_delete.rb | 35 +---------------- app/models/runtime/service_account_model.rb | 4 +- .../service_account_event_repository.rb | 3 +- ...0_allow_accounts_without_original_space.rb | 11 ++++++ spec/unit/actions/organization_delete_spec.rb | 2 +- .../service_account_org_lifecycle_spec.rb | 2 +- spec/unit/actions/space_delete_spec.rb | 38 +++++++++---------- 8 files changed, 60 insertions(+), 59 deletions(-) create mode 100644 db/migrations/20261109122000_allow_accounts_without_original_space.rb diff --git a/app/actions/organization_delete.rb b/app/actions/organization_delete.rb index 76327654ed1..0ad822dfbdf 100644 --- a/app/actions/organization_delete.rb +++ b/app/actions/organization_delete.rb @@ -17,6 +17,9 @@ def delete(org_dataset) return [CloudController::Errors::ApiError.new_from_details('OrganizationDeletionFailed', org.name, error_message)] end + account_errors = delete_service_accounts(org) + return [CloudController::Errors::ApiError.new_from_details('OrganizationDeletionFailed', org.name, account_errors.map(&:message).join("\n\n"))] unless account_errors.empty? + domains_to_unshare = [] org.private_domains.each do |private_domain| if private_domain.owning_organization == org @@ -46,6 +49,27 @@ def timeout_error(dataset) private + def delete_service_accounts(org) + accounts = ServiceAccountModel.where(organization_id: org.id).order(:guid).all + return [] if accounts.empty? + + provisioner = CloudController::DependencyLocator.instance.service_account_provisioner + accounts.each_with_object([]) do |account, errors| + account.db.transaction do + 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 + Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) + provisioner.deprovision(account, delete: true) + rescue StandardError => e + errors << e + end + end + def unshare_private_domains(domains_to_unshare, org_model) # find all private domains that are shared with this org # for each one, unshare the org diff --git a/app/actions/space_delete.rb b/app/actions/space_delete.rb index 707d0aa683a..f11835e8310 100644 --- a/app/actions/space_delete.rb +++ b/app/actions/space_delete.rb @@ -25,13 +25,9 @@ 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) + ServiceAccountModel.where(space_guid: space_model.guid).update(space_guid: nil) space_model.destroy Repositories::SpaceEventRepository.new.record_space_delete_request(space_model, @user_audit_info, true) end @@ -85,35 +81,6 @@ def delete_apps(space_model) 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) broker_remover = service_broker_remover(@services_event_repository) private_service_brokers = space_model.service_brokers diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 3640ad12b63..f2ceb3f4f0d 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -21,13 +21,13 @@ def before_validation def validate super - validates_presence :space + validates_presence :space if new? validates_presence :organization errors.add(:organization, 'must own the initial space') if new? && space && organization_id != space.organization_id errors.add(:organization, 'is immutable') if !new? && column_changed?(:organization_id) 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) + errors.add(:space, 'is immutable') if !new? && column_changed?(:space_guid) && space_guid end def around_create diff --git a/app/repositories/service_account_event_repository.rb b/app/repositories/service_account_event_repository.rb index 4e3077d2af0..415fe9e4635 100644 --- a/app/repositories/service_account_event_repository.rb +++ b/app/repositories/service_account_event_repository.rb @@ -4,7 +4,8 @@ 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, + space_guid: resource.space&.guid || '', + organization_guid: resource.is_a?(AppModel) ? resource.space.organization.guid : resource.organization.guid, actee: resource.guid, actee_type: resource.is_a?(AppModel) ? 'app' : 'service_account', actee_name: resource.name, diff --git a/db/migrations/20261109122000_allow_accounts_without_original_space.rb b/db/migrations/20261109122000_allow_accounts_without_original_space.rb new file mode 100644 index 00000000000..75aa19e3010 --- /dev/null +++ b/db/migrations/20261109122000_allow_accounts_without_original_space.rb @@ -0,0 +1,11 @@ +Sequel.migration do + up do + alter_table(:service_accounts) { set_column_allow_null :space_guid } + end + + down do + raise Sequel::Error.new('Cannot restore space ownership while accounts have no original space') if from(:service_accounts).where(space_guid: nil).any? + + alter_table(:service_accounts) { set_column_not_null :space_guid } + end +end diff --git a/spec/unit/actions/organization_delete_spec.rb b/spec/unit/actions/organization_delete_spec.rb index 19596e89175..6c0e0d5dad9 100644 --- a/spec/unit/actions/organization_delete_spec.rb +++ b/spec/unit/actions/organization_delete_spec.rb @@ -98,7 +98,7 @@ module VCAP::CloudController 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 + expect(Space.first(guid: space.guid)).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 diff --git a/spec/unit/actions/service_account_org_lifecycle_spec.rb b/spec/unit/actions/service_account_org_lifecycle_spec.rb index dc356345886..23b7b84b1fc 100644 --- a/spec/unit/actions/service_account_org_lifecycle_spec.rb +++ b/spec/unit/actions/service_account_org_lifecycle_spec.rb @@ -34,7 +34,7 @@ module VCAP::CloudController expect(account.entitled_spaces).to be_empty expect(account.space_guid).to be_nil expect(User.first(guid: principal.guid)).not_to be_nil - expect(org.users_dataset.where(id: principal.id)).to exist + expect(org.users_dataset.where(users__id: principal.id).any?).to be(true) expect(AppModel.first(guid: app.guid)).to be_nil expect(clients).not_to have_received(:delete) end diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index 85d93f7b8cc..16776a0570b 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -40,7 +40,7 @@ module VCAP::CloudController allow(clients).to receive(:delete) end - it 'deletes assigned workloads, managed clients and principals with explicit roles, retaining names' do + it 'deletes assigned workloads while retaining the account and principal' do provisioner.provision(account) principal = User.first(guid: account.client_id) space.organization.add_user(principal) @@ -58,15 +58,16 @@ module VCAP::CloudController 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(clients).not_to have_received(:delete) + expect(account.reload.status).to eq('ready') + expect(account.entitled_spaces).to be_empty + expect(User.first(guid: principal.guid)).not_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) + expect(Event.where(type: 'audit.service_account.delete', actee: account.guid).count).to eq(0) end - it 'retains the space on UAA failure and allows cleanup to be retried' do + it 'deletes the space independently of UAA availability' do provisioner.provision(account) app.update(service_account: account) allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) @@ -74,37 +75,34 @@ module VCAP::CloudController 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(errors).to be_empty + expect(account.reload.enabled).to be(true) + expect(account.status).to eq('ready') expect(Space.first(guid: space.guid)).to be_nil end - it 'reports active account reconciliation before deleting workloads' do + it 'retains account reconciliation while deleting the space 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(errors).to be_empty + expect(AppModel.first(guid: app.guid)).to be_nil + expect(Space.first(guid: space.guid)).to be_nil + expect(ServiceAccountModel.first(guid: account.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(errors).to be_empty expect(ServiceAccountModel.first(guid: account.guid)).not_to be_nil - expect(Space.first(guid: space.guid)).not_to be_nil + expect(Space.first(guid: space.guid)).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 { space_delete.delete([space]) }.to raise_error('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) From a446b3c80d6385b14b7e5f9415406de357c288c3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 14:54:30 +0100 Subject: [PATCH 072/200] Test account space entitlement APIs and developer permissions (red) --- .../service_account_entitlements_spec.rb | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 spec/request/service_account_entitlements_spec.rb diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb new file mode 100644 index 00000000000..e56bfe4bf15 --- /dev/null +++ b/spec/request/service_account_entitlements_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Service account space entitlements' do + let(:org) { create(:organization) } + let(:space) { create(:space, organization: org) } + let(:target) { create(:space, organization: org) } + let(:user) { create(:user) } + let(:account) { ServiceAccountModel.create(name: 'shared-worker', space: space, status: 'ready') } + let(:path) { "/v3/service_accounts/#{account.guid}/relationships/spaces" } + + def auth(role) + set_user_with_header_as_role(role: role, org: org, space: space, user: user) + end + + def entitle(destination, headers) + post path, { data: [{ guid: destination.guid }] }.to_json, headers + end + + it 'allows a developer of both spaces to entitle and bind a second space' do + headers = auth('space_developer') + target.add_developer(user) + entitle(target, headers) + expect(last_response.status).to eq(200) + expect(account.reload.entitled_spaces).to contain_exactly(space, target) + app = create(:app_model, space: target) + patch "/v3/apps/#{app.guid}/relationships/service_account", { data: { guid: account.guid } }.to_json, headers + expect(last_response.status).to eq(200) + expect(app.reload.service_account_guid).to eq(account.guid) + end + + it 'requires development access to both an entitled source and destination space' do + headers = auth('space_developer') + entitle(target, headers) + expect(last_response.status).to eq(403) + expect(account.reload.entitled_spaces).to contain_exactly(space) + end + + it 'rejects cross-org entitlements even for admins' do + entitle(create(:space), auth('admin')) + expect(last_response.status).to eq(422) + expect(account.reload.entitled_spaces).to contain_exactly(space) + end + + it 'shows org managers zero-entitlement accounts and filters by organization' do + headers = auth('org_manager') + account.remove_all_entitled_spaces + get "/v3/service_accounts/#{account.guid}", nil, headers + expect(last_response.status).to eq(200) + expect(parsed_response.dig('relationships', 'organization', 'data', 'guid')).to eq(org.guid) + get "/v3/service_accounts?organization_guids=#{org.guid}", nil, headers + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to eq([account.guid]) + end + + it 'blocks revocation while desired or active workload references use the account' do + headers = auth('org_manager') + entitle(target, headers) + expect(last_response.status).to eq(200) + app = create(:app_model, space: target, service_account: account) + delete "#{path}/#{target.guid}", nil, headers + expect(last_response.status).to eq(409) + app.update(service_account: nil) + process = create(:process_model, app: app, state: ProcessModel::STARTED, service_account_guid: account.guid, service_account_snapshot: true) + delete "#{path}/#{target.guid}", nil, headers + expect(last_response.status).to eq(409) + process.destroy + delete "#{path}/#{target.guid}", nil, headers + expect(last_response.status).to eq(200) + end + + it 'does not let a developer revoke the last entitlement but permits org managers' do + delete "#{path}/#{space.guid}", nil, auth('space_developer') + expect(last_response.status).to eq(409) + delete "#{path}/#{space.guid}", nil, auth('org_manager') + expect(last_response.status).to eq(200) + expect(account.reload.entitled_spaces).to be_empty + end + + it 'allows a developer to create an account with an initial entitlement' do + body = { name: 'new-worker', relationships: { space: { data: { guid: space.guid } } } } + post '/v3/service_accounts', body.to_json, auth('space_developer') + expect(last_response.status).to eq(201) + expect(ServiceAccountModel.first(guid: parsed_response['guid']).entitled_spaces).to contain_exactly(space) + end + end +end From cc5efc5cc0287974242dee4d6999591042bd01ab Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:00:41 +0100 Subject: [PATCH 073/200] Authorize account use and management through org space entitlements --- app/actions/app_assign_service_account.rb | 2 +- .../v3/service_accounts_controller.rb | 95 +++++++++++++++++-- app/messages/service_accounts_list_message.rb | 6 +- app/models/runtime/app_model.rb | 2 +- app/models/runtime/service_account_model.rb | 11 +++ .../v3/service_account_presenter.rb | 4 +- config/routes.rb | 3 + .../service_account_entitlements_spec.rb | 1 + spec/request/service_accounts_spec.rb | 58 +++++------ .../app_assign_service_account_spec.rb | 2 +- 10 files changed, 141 insertions(+), 43 deletions(-) diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 5a848b47d2b..70542439ded 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -19,7 +19,7 @@ def assign(app, account, provision: false) 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('account must be entitled to the app space in the same organization') unless account.entitled_to?(app.space) 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 diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index fb978e9205d..bb7291fcd42 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -13,9 +13,16 @@ 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? + unless permission_queryer.can_read_globally? + entitled = ServiceAccountModel.db[:service_account_space_entitlements].where(space_guid: permission_queryer.readable_space_guids_query).select(:service_account_guid) + managed_orgs = current_user.managed_organizations_dataset.select(:id) + dataset = dataset.where(Sequel.|({ guid: entitled }, { organization_id: managed_orgs })) + end dataset = dataset.where(name: message.names) if message.requested?(:names) - dataset = dataset.where(space_guid: message.space_guids) if message.requested?(:space_guids) + if message.requested?(:space_guids) + dataset = dataset.where(guid: ServiceAccountModel.db[:service_account_space_entitlements].where(space_guid: message.space_guids).select(:service_account_guid)) + end + dataset = dataset.where(organization_id: Organization.where(guid: message.organization_guids).select(:id)) if message.requested?(:organization_guids) render_list(dataset, message, Presenters::V3::ServiceAccountPresenter, '/v3/service_accounts') end @@ -23,7 +30,10 @@ def apps account = readable_account message = AppsListMessage.from_params(query_params) invalid_param!(message.errors.full_messages) unless message.valid? - dataset = AppListFetcher.fetch(message, [account.space_guid]).where(service_account_guid: account.guid) + spaces = account.entitled_spaces_dataset + spaces = spaces.where(guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally? + spaces = spaces.select_map(:guid) + dataset = AppListFetcher.fetch(message, spaces).where(service_account_guid: account.guid) render_list(dataset, message, Presenters::V3::AppPresenter, "/v3/service_accounts/#{account.guid}/apps") end @@ -39,7 +49,8 @@ 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) - authorize_management!(space) + unauthorized! unless permission_queryer.can_write_to_active_space?(space.id) + require_writable_space!(space) account = nil ServiceAccountModel.db.transaction do @@ -58,7 +69,7 @@ def create def update account = readable_account - authorize_management!(account.space) + authorize_account_management!(account) message = ServiceAccountUpdateMessage.new(hashed_params[:body]) unprocessable!(message.errors.full_messages) unless message.valid? job = nil @@ -80,7 +91,7 @@ def update def destroy account = readable_account - authorize_management!(account.space) + authorize_account_management!(account) require_provisioning! job = nil account.db.transaction do @@ -96,6 +107,76 @@ def destroy private + def org_manager?(account) + permission_queryer.can_write_globally? || account.organization.managers_dataset.where(users__id: current_user.id).any? + end + + def developer?(space) + space.developers_dataset.where(users__id: current_user.id).any? + end + + def authorize_account_management!(account) + unauthorized! unless org_manager?(account) || account.entitled_spaces.any? { |space| developer?(space) } + require_writable_org!(account.organization) + end + + public + + def spaces + account = readable_account + render status: :ok, json: { data: account.entitled_spaces.map { |space| { guid: space.guid } } } + end + + def entitle + account = readable_account + data = entitlement_guids + account.db.transaction do + account.lock! + require_writable_org!(account.organization) + data.each do |guid| + space = Space.first(guid: guid) + unprocessable!('Space must belong to the account organization') unless space && space.organization_id == account.organization_id + unauthorized! unless org_manager?(account) || (developer?(space) && account.entitled_spaces.any? { |source| developer?(source) }) + require_writable_space!(space) + account.add_entitled_space(space) unless account.entitled_spaces_dataset.where(spaces__guid: space.guid).any? + Repositories::ServiceAccountEventRepository.record(account, 'entitle', user_audit_info, space_guid: space.guid) + end + end + spaces + end + + def unentitle + account = readable_account + account.db.transaction do + account.lock! + require_writable_org!(account.organization) + space = account.entitled_spaces_dataset.where(spaces__guid: hashed_params[:space_guid]).first + resource_not_found!(:space) unless space + unauthorized! unless org_manager?(account) || developer?(space) + lifecycle_conflict!('cannot revoke the last entitlement; delete the account instead') if !org_manager?(account) && account.entitled_spaces_dataset.one? + lifecycle_conflict!('service account is still assigned or in use in this space') if account.in_use_in_space?(space) + account.remove_entitled_space(space) + Repositories::ServiceAccountEventRepository.record(account, 'unentitle', user_audit_info, space_guid: space.guid) + end + spaces + end + + private + + def entitlement_guids + data = hashed_params[:body]['data'] || hashed_params[:body][:data] + unprocessable!('Data must be an array of space GUIDs') unless data.is_a?(Array) + data.map do |entry| + unprocessable!('Data must be an array of space GUIDs') unless entry.is_a?(Hash) && (entry['guid'] || entry[:guid]).is_a?(String) + entry['guid'] || entry[:guid] + end + end + + def readable?(account) + permission_queryer.can_read_globally? || org_manager?(account) || + account.entitled_spaces.any? { |space| permission_queryer.can_read_from_space?(space.id, space.organization_id) } + end + def require_provisioning! lifecycle_conflict!('service account provisioning is not enabled') unless Config.config.get(:service_account_provisioning_enabled) == true end @@ -116,7 +197,7 @@ def enqueue_lifecycle(account, operation) 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) + resource_not_found!(:service_account) unless account && readable?(account) account end diff --git a/app/messages/service_accounts_list_message.rb b/app/messages/service_accounts_list_message.rb index bfa960a7f22..ef4e83f9471 100644 --- a/app/messages/service_accounts_list_message.rb +++ b/app/messages/service_accounts_list_message.rb @@ -2,12 +2,12 @@ module VCAP::CloudController class ServiceAccountsListMessage < ListMessage - register_allowed_keys %i[names space_guids] + register_allowed_keys %i[names space_guids organization_guids] validates_with NoAdditionalParamsValidator - validates :names, :space_guids, array: true, allow_nil: true + validates :names, :space_guids, :organization_guids, array: true, allow_nil: true def self.from_params(params) - super(params, %w[names space_guids]) + super(params, %w[names space_guids organization_guids]) end def valid_order_by_values diff --git a/app/models/runtime/app_model.rb b/app/models/runtime/app_model.rb index d87a4399943..26a362518eb 100644 --- a/app/models/runtime/app_model.rb +++ b/app/models/runtime/app_model.rb @@ -95,7 +95,7 @@ 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 + errors.add(:service_account, 'must be entitled to the app space in the same organization') if service_account && !service_account.entitled_to?(space) 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 index f2ceb3f4f0d..48827b6004c 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -57,6 +57,17 @@ def in_use? TaskModel.where(service_account_guid: guid, state: [TaskModel::PENDING_STATE, TaskModel::RUNNING_STATE, TaskModel::CANCELING_STATE]).any? end + def in_use_in_space?(space) + app_guids = AppModel.where(space_guid: space.guid).select(:guid) + apps_dataset.where(space_guid: space.guid).any? || + ProcessModel.where(app_guid: app_guids, service_account_guid: guid, state: ProcessModel::STARTED).any? || + TaskModel.where(app_guid: app_guids, service_account_guid: guid, state: [TaskModel::PENDING_STATE, TaskModel::RUNNING_STATE, TaskModel::CANCELING_STATE]).any? + end + + def entitled_to?(space) + organization_id == space.organization_id && entitled_spaces_dataset.where(spaces__guid: space.guid).any? + end + def certificate_dns_san "#{name}.svc.identity" end diff --git a/app/presenters/v3/service_account_presenter.rb b/app/presenters/v3/service_account_presenter.rb index 4e410709713..6ce36d05405 100644 --- a/app/presenters/v3/service_account_presenter.rb +++ b/app/presenters/v3/service_account_presenter.rb @@ -19,7 +19,9 @@ def to_hash certificate_dns_san: @resource.certificate_dns_san, enabled: @resource.enabled, status: @resource.status, - relationships: { space: { data: { guid: @resource.space_guid } } }, + relationships: { organization: { data: { guid: @resource.organization.guid } }, + spaces: { data: @resource.entitled_spaces.map { |space| { guid: space.guid } } }, + space: { data: @resource.space_guid ? { guid: @resource.space_guid } : nil } }, links: { self: { href: url_builder.build_url(path: "/v3/service_accounts/#{@resource.guid}") } } } end diff --git a/config/routes.rb b/config/routes.rb index 612c691e9fd..996f9c0cab4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,9 @@ 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/relationships/spaces', to: 'service_accounts#spaces' + post '/service_accounts/:guid/relationships/spaces', to: 'service_accounts#entitle' + delete '/service_accounts/:guid/relationships/spaces/:space_guid', to: 'service_accounts#unentitle' 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/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index e56bfe4bf15..fefc2dcdda6 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -62,6 +62,7 @@ def entitle(destination, headers) expect(last_response.status).to eq(409) app.update(service_account: nil) process = create(:process_model, app: app, state: ProcessModel::STARTED, service_account_guid: account.guid, service_account_snapshot: true) + process.update(service_account_guid: account.guid, service_account_snapshot: true) delete "#{path}/#{target.guid}", nil, headers expect(last_response.status).to eq(409) process.destroy diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 4787d45e1bd..1bf369c0a5c 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -10,8 +10,8 @@ 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') + it 'allows a space developer to reserve an identity without provisioning a client' do + post '/v3/service_accounts', body.to_json, headers('space_developer') expect(last_response.status).to eq(201) result = Oj.load(last_response.body) @@ -34,12 +34,12 @@ def headers(role) before { TestConfig.override(service_account_creation_limit: 1) } it 'limits a principal across spaces without refunding deleted accounts' do - auth = headers('space_manager') + auth = headers('space_developer') 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_space.add_developer(user) other_body = { name: 'other-worker', relationships: { space: { data: { guid: other_space.guid } } } } post '/v3/service_accounts', other_body.to_json, auth @@ -50,7 +50,7 @@ def headers(role) end it 'uses a rolling seven-day window' do - auth = headers('space_manager') + auth = headers('space_developer') Timecop.freeze(Time.utc(2026, 10, 1)) do post '/v3/service_accounts', body.to_json, auth expect(last_response.status).to eq(201) @@ -66,7 +66,7 @@ def headers(role) end it 'does not consume budget on a failed creation' do - auth = headers('space_manager') + auth = headers('space_developer') VCAP::CloudController::ServiceAccountModel.create(name: body[:name], space: space) post '/v3/service_accounts', body.to_json, auth expect(last_response.status).to eq(409) @@ -77,17 +77,17 @@ def headers(role) end it 'does not share budget between principals' do - post '/v3/service_accounts', body.to_json, headers('space_manager') + post '/v3/service_accounts', body.to_json, headers('space_developer') 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) + auth = set_user_with_header_as_role(role: 'space_developer', 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') + auth = headers('space_developer') %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) @@ -101,14 +101,14 @@ def headers(role) 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') + post '/v3/service_accounts', body.to_json, headers('space_developer') 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) + space.add_developer(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)}") @@ -123,10 +123,10 @@ def headers(role) end end - it 'denies a space developer account-creation authority' do + it 'allows 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) + expect(last_response.status).to eq(201) + expect(VCAP::CloudController::ServiceAccountModel.count).to eq(1) end it 'rejects invalid names without normalizing them' do @@ -153,7 +153,7 @@ def headers(role) it 'denies creation in a suspended organization' do org.update(status: 'suspended') - post '/v3/service_accounts', body.to_json, headers('space_manager') + post '/v3/service_accounts', body.to_json, headers('space_developer') expect(last_response.status).to eq(403) end @@ -183,12 +183,12 @@ def headers(role) 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') + post '/v3/service_accounts', body.to_json, headers('space_developer') 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') + auth = headers('space_developer') 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) @@ -223,10 +223,10 @@ def headers(role) expect(Oj.load(last_response.body)['resources']).to eq([]) end - it 'denies developers updates and hides unreadable resources' do + it 'allows developer 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) + expect(last_response.status).to eq(200) 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) @@ -235,7 +235,7 @@ def headers(role) %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') + patch "/v3/service_accounts/#{own.guid}", { key => 'injected' }.to_json, headers('space_developer') expect(last_response.status).to eq(422) end end @@ -268,7 +268,7 @@ def headers(role) space.add_developer(principal) registration = provisioner.send(:registration, account) allow(clients).to receive(:get).and_return(registration) - auth = headers('space_manager') + auth = headers('space_developer') patch account_path, { enabled: false }.to_json, auth expect(last_response.status).to eq(202) @@ -290,7 +290,7 @@ def headers(role) provisioner.provision(account) allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) guid = account.guid - auth = headers('space_manager') + auth = headers('space_developer') delete account_path, nil, auth expect(last_response.status).to eq(202) expect(account.reload.status).to eq('deleting') @@ -304,22 +304,22 @@ def headers(role) 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') + delete account_path, nil, headers('space_developer') 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') + it 'denies auditor deletion and rejects non-boolean enabled values' do + delete account_path, nil, headers('space_auditor') expect(last_response.status).to eq(403) - patch account_path, { enabled: 'false' }.to_json, headers('space_manager') + patch account_path, { enabled: 'false' }.to_json, headers('space_developer') 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') + delete account_path, nil, headers('space_developer') expect(last_response.status).to eq(202) expect(Delayed::Worker.new.work_off).to eq([0, 1]) expect(clients).not_to have_received(:delete) @@ -330,7 +330,7 @@ def headers(role) app = create(:app_model, space: space) 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') + delete account_path, nil, headers('space_developer') expect(last_response.status).to eq(409) expect(Delayed::Job.count).to eq(0) end @@ -338,7 +338,7 @@ def headers(role) 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') + delete account_path, nil, headers('space_developer') expect(last_response.status).to eq(409) expect(Delayed::Job.count).to eq(0) end diff --git a/spec/unit/actions/app_assign_service_account_spec.rb b/spec/unit/actions/app_assign_service_account_spec.rb index e2e91617322..d974bedce5f 100644 --- a/spec/unit/actions/app_assign_service_account_spec.rb +++ b/spec/unit/actions/app_assign_service_account_spec.rb @@ -23,7 +23,7 @@ module VCAP::CloudController 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/) + expect { action.assign(app, other) }.to raise_error(AppAssignServiceAccount::Conflict, /entitled/) end it 'is idempotent but requires explicit unbinding before replacement' do From d12106400d6952a4da174adf5547fba81036a944 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:02:08 +0100 Subject: [PATCH 074/200] Test qualified org-local account names and explicit tombstone reuse (red) --- .../service_account_namespaces_spec.rb | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 spec/request/service_account_namespaces_spec.rb diff --git a/spec/request/service_account_namespaces_spec.rb b/spec/request/service_account_namespaces_spec.rb new file mode 100644 index 00000000000..f4fd637a01b --- /dev/null +++ b/spec/request/service_account_namespaces_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Service account org namespaces' do + let(:user) { create(:user) } + let(:org) { create(:organization, service_account_prefix: 'acme-payments') } + let(:space) { create(:space, organization: org) } + let(:body) { { name: 'payments-worker', relationships: { space: { data: { guid: space.guid } } } } } + + def auth(role='space_developer') + set_user_with_header_as_role(role: role, org: org, space: space, user: user) + end + + it 'derives a qualified subject and SAN and permits the same name in another org' do + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(201) + expect(parsed_response).to include('client_id' => 'cf:service-account:acme-payments:payments-worker', + 'certificate_dns_san' => 'payments-worker.acme-payments.svc.identity') + other = create(:space, organization: create(:organization, service_account_prefix: 'other-team')) + post '/v3/service_accounts', body.merge(relationships: { space: { data: { guid: other.guid } } }).to_json, admin_headers_for(user) + expect(last_response.status).to eq(201) + end + + it 'rejects creation when the org prefix is unset' do + other = create(:space) + post '/v3/service_accounts', body.merge(relationships: { space: { data: { guid: other.guid } } }).to_json, admin_headers_for(user) + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to include('prefix') + end + + it 'timestamps deletion and lets an org manager explicitly reuse the name with a new UUID and audit' do + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(201) + old_guid = parsed_response['guid'] + ServiceAccountModel.first(guid: old_guid).destroy + reservation = ServiceAccountModel.db[:service_account_names].first(name: 'payments-worker', organization_id: org.id) + expect(reservation[:deleted_at]).to be_a(Time) + post '/v3/service_accounts', body.to_json, auth + expect(last_response.status).to eq(409) + post '/v3/service_accounts', body.merge(reuse_name: true).to_json, auth + expect(last_response.status).to eq(403) + post '/v3/service_accounts', body.merge(reuse_name: true).to_json, auth('org_manager') + expect(last_response.status).to eq(201) + expect(parsed_response['guid']).not_to eq(old_guid) + expect(parsed_response['status']).to eq('reserved') + expect(Event.first(type: 'audit.service_account.create', actee: parsed_response['guid']).metadata).to include('reuse_name' => true) + end + end +end From 637527f94c111228b4ddb2e3961a098cf7c87429 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:05:31 +0100 Subject: [PATCH 075/200] Scope account identities and tombstones to immutable org namespaces --- .../v3/service_accounts_controller.rb | 18 ++++++++++-- .../service_account_create_message.rb | 3 +- app/models/runtime/service_account_model.rb | 17 +++++++++-- ..._service_account_names_to_organizations.rb | 28 +++++++++++++++++++ .../service_account_entitlements_spec.rb | 2 +- .../service_account_namespaces_spec.rb | 4 +-- spec/request/service_accounts_spec.rb | 4 +-- .../runtime/service_account_model_spec.rb | 8 +++--- 8 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 db/migrations/20261109123000_scope_service_account_names_to_organizations.rb diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index bb7291fcd42..d4fe4d9b239 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -49,15 +49,21 @@ 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_to_active_space?(space.id) + reuse = authorize_creation!(space, message) require_writable_space!(space) + unprocessable!('Organization service account prefix is unset') unless space.organization.service_account_prefix account = nil ServiceAccountModel.db.transaction do + space.organization.lock! ServiceAccountCreationBudget.consume(current_user, exempt: permission_queryer.can_write_globally?) do + if reuse + lifecycle_conflict!('live account name is already in use') if ServiceAccountModel.where(organization_id: space.organization_id, name: message.name).any? + ServiceAccountModel.db[:service_account_names].where(organization_id: space.organization_id, name: message.name).delete + end account = ServiceAccountModel.create(name: message.name, space: space) apply_metadata(account, message) - Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info) + Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info, reuse_name: reuse) end end render status: :created, json: Presenters::V3::ServiceAccountPresenter.new(account) @@ -107,6 +113,14 @@ def destroy private + def authorize_creation!(space, message) + reuse = message.reuse_name == true + manager = permission_queryer.can_write_globally? || space.organization.managers_dataset.where(users__id: current_user.id).any? + unauthorized! if reuse && !manager + unauthorized! unless permission_queryer.can_write_to_active_space?(space.id) || (reuse && manager) + reuse + end + def org_manager?(account) permission_queryer.can_write_globally? || account.organization.managers_dataset.where(users__id: current_user.id).any? end diff --git a/app/messages/service_account_create_message.rb b/app/messages/service_account_create_message.rb index 4c9b0e82a65..ecfc42e9f2f 100644 --- a/app/messages/service_account_create_message.rb +++ b/app/messages/service_account_create_message.rb @@ -2,7 +2,8 @@ module VCAP::CloudController class ServiceAccountCreateMessage < MetadataBaseMessage - register_allowed_keys %i[name relationships description] + register_allowed_keys %i[name relationships description reuse_name] + validates :reuse_name, boolean: true, allow_nil: true validates :description, string: true, allow_nil: true, length: { maximum: 250 } validates_with NoAdditionalKeysValidator, RelationshipValidator validates :name, presence: true, string: true, diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 48827b6004c..c5a4f1ef4e3 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -16,6 +16,7 @@ class ServiceAccountModel < Sequel::Model(:service_accounts) def before_validation self.organization = space.organization if new? && organization_id.nil? && space + self.identity_prefix = organization.service_account_prefix if new? && identity_prefix.nil? && organization super end @@ -25,16 +26,21 @@ def validate validates_presence :organization errors.add(:organization, 'must own the initial space') if new? && space && organization_id != space.organization_id errors.add(:organization, 'is immutable') if !new? && column_changed?(:organization_id) + validate_identity_immutability 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) && space_guid end + def validate_identity_immutability + errors.add(:identity_prefix, 'is immutable') if !new? && column_changed?(:identity_prefix) + 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. + db[:service_account_names].insert(name: name, organization_id: organization_id) # rubocop:disable Rails/SkipsModelValidations -- Atomic namespace reservation, not an ActiveRecord model. yield end rescue Sequel::UniqueConstraintViolation @@ -48,7 +54,7 @@ def after_create end def client_id - "cf:service-account:#{name}" + identity_prefix ? "cf:service-account:#{identity_prefix}:#{name}" : "cf:service-account:#{name}" end def in_use? @@ -69,7 +75,12 @@ def entitled_to?(space) end def certificate_dns_san - "#{name}.svc.identity" + identity_prefix ? "#{name}.#{identity_prefix}.svc.identity" : "#{name}.svc.identity" + end + + def before_destroy + db[:service_account_names].where(name: name, organization_id: organization_id).update(deleted_at: Sequel::CURRENT_TIMESTAMP) + super end end end diff --git a/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb b/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb new file mode 100644 index 00000000000..8a6d6d37ee9 --- /dev/null +++ b/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb @@ -0,0 +1,28 @@ +Sequel.migration do + up do + organization_id_type = schema(:organizations).to_h.fetch(:id).fetch(:db_type) + alter_table :service_accounts do + drop_foreign_key [:name], name: :fk_service_accounts_name + drop_index :name, name: :service_accounts_name_unique, concurrently: false + add_column :identity_prefix, String, size: 63 + end + alter_table :service_account_names do + add_column :organization_id, organization_id_type + add_column :deleted_at, DateTime + drop_index :name, name: :service_account_names_name_unique, concurrently: false + add_index %i[organization_id name], unique: true, name: :service_account_names_org_name_unique, concurrently: false + end + from(:service_accounts).each do |account| + from(:service_account_names).where(name: account[:name]).update(organization_id: account[:organization_id]) + end + # Existing unqualified accounts and orphan historical reservations are retained. + # Do not infer a deleted org or historical deletion time from absent evidence. + alter_table :service_accounts do + add_index %i[organization_id name], unique: true, name: :service_accounts_org_name_unique, concurrently: false + end + end + + down do + raise Sequel::Error.new('Org-local identities cannot be downgraded to the global account namespace') + end +end diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index fefc2dcdda6..4d900535ba7 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -2,7 +2,7 @@ module VCAP::CloudController RSpec.describe 'Service account space entitlements' do - let(:org) { create(:organization) } + let(:org) { create(:organization, service_account_prefix: 'test-team') } let(:space) { create(:space, organization: org) } let(:target) { create(:space, organization: org) } let(:user) { create(:user) } diff --git a/spec/request/service_account_namespaces_spec.rb b/spec/request/service_account_namespaces_spec.rb index f4fd637a01b..c45eff96902 100644 --- a/spec/request/service_account_namespaces_spec.rb +++ b/spec/request/service_account_namespaces_spec.rb @@ -14,8 +14,8 @@ def auth(role='space_developer') it 'derives a qualified subject and SAN and permits the same name in another org' do post '/v3/service_accounts', body.to_json, auth expect(last_response.status).to eq(201) - expect(parsed_response).to include('client_id' => 'cf:service-account:acme-payments:payments-worker', - 'certificate_dns_san' => 'payments-worker.acme-payments.svc.identity') + expect(parsed_response['client_id']).to eq('cf:service-account:acme-payments:payments-worker') + expect(parsed_response['certificate_dns_san']).to eq('payments-worker.acme-payments.svc.identity') other = create(:space, organization: create(:organization, service_account_prefix: 'other-team')) post '/v3/service_accounts', body.merge(relationships: { space: { data: { guid: other.guid } } }).to_json, admin_headers_for(user) expect(last_response.status).to eq(201) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 1bf369c0a5c..b5e5c74f655 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' RSpec.describe 'Service accounts' do - let(:org) { create(:organization) } + let(:org) { create(:organization, service_account_prefix: 'test-team') } let(:space) { create(:space, organization: org) } let(:user) { create(:user) } let(:body) { { name: 'payments-worker', relationships: { space: { data: { guid: space.guid } } } } } @@ -16,7 +16,7 @@ def headers(role) 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') + 'client_id' => 'cf:service-account:test-team:payments-worker', 'certificate_dns_san' => 'payments-worker.test-team.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']) diff --git a/spec/unit/models/runtime/service_account_model_spec.rb b/spec/unit/models/runtime/service_account_model_spec.rb index 226ba2708f0..c1dd41e0503 100644 --- a/spec/unit/models/runtime/service_account_model_spec.rb +++ b/spec/unit/models/runtime/service_account_model_spec.rb @@ -32,9 +32,9 @@ def reserve(name, owner=space) end end - it 'enforces foundation-wide uniqueness across owners in the database' do + it 'enforces org-local uniqueness across spaces in the database' do account = reserve('payments-worker') - other_owner = create(:space) + other_owner = create(:space, organization: space.organization) expect { reserve(account.name, other_owner) }.to raise_error(Sequel::ValidationFailed, /name/) expect do @@ -52,12 +52,12 @@ def reserve(name, owner=space) expect(account.reload.name).to eq('payments-worker') end - it 'retains the name after deletion so another owner cannot inherit it' do + it 'retains the name after deletion within the owning org' 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/) + expect { reserve('payments-worker', create(:space, organization: space.organization)) }.to raise_error(Sequel::ValidationFailed, /name/) end end end From c9016a0392232ed4aee1cfb5161f2cb34c17bd38 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:06:36 +0100 Subject: [PATCH 076/200] Test org account quota API and capacity enforcement (red) --- spec/request/service_account_quotas_spec.rb | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 spec/request/service_account_quotas_spec.rb diff --git a/spec/request/service_account_quotas_spec.rb b/spec/request/service_account_quotas_spec.rb new file mode 100644 index 00000000000..0b58aa07f84 --- /dev/null +++ b/spec/request/service_account_quotas_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Service account org quotas' do + let(:user) { create(:user) } + let(:org) { create(:organization, service_account_prefix: 'quota-team') } + let(:space) { create(:space, organization: org) } + let(:headers) { admin_headers_for(user) } + + it 'persists quota limits, counts disabled accounts, and frees capacity on deletion without freeing names' do + post '/v3/organization_quotas', { name: 'account-quota', service_accounts: { total_service_accounts: 1 } }.to_json, headers + expect(last_response.status).to eq(201) + expect(parsed_response['service_accounts']).to eq('total_service_accounts' => 1) + quota = QuotaDefinition.first(guid: parsed_response['guid']) + org.update(quota_definition: quota) + body = { name: 'first-worker', relationships: { space: { data: { guid: space.guid } } } } + post '/v3/service_accounts', body.to_json, headers + expect(last_response.status).to eq(201) + account = ServiceAccountModel.first(guid: parsed_response['guid']) + account.update(enabled: false) + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, headers + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to include('quota') + account.destroy + post '/v3/service_accounts', body.merge(name: 'second-worker').to_json, headers + expect(last_response.status).to eq(201) + post '/v3/service_accounts', body.to_json, headers + expect(last_response.status).not_to eq(201) + patch "/v3/organization_quotas/#{quota.guid}", { service_accounts: { total_service_accounts: nil } }.to_json, headers + expect(last_response.status).to eq(200) + expect(parsed_response['service_accounts']).to eq('total_service_accounts' => nil) + end + + [-1, 'one', 1.5].each do |limit| + it "rejects invalid limit #{limit.inspect}" do + post '/v3/organization_quotas', { name: 'invalid-quota', service_accounts: { total_service_accounts: limit } }.to_json, headers + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to include('Service accounts') + end + end + end +end From e46228c2938c3bde4b677cd0decd097697c086b2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:08:40 +0100 Subject: [PATCH 077/200] Enforce organization service account capacity quotas --- app/actions/organization_quotas_create.rb | 1 + app/actions/organization_quotas_update.rb | 1 + .../v3/service_accounts_controller.rb | 2 ++ .../organization_quotas_update_message.rb | 22 ++++++++++++++++++- .../v3/organization_quota_presenter.rb | 1 + ...109124000_add_org_service_account_quota.rb | 5 +++++ 6 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 db/migrations/20261109124000_add_org_service_account_quota.rb diff --git a/app/actions/organization_quotas_create.rb b/app/actions/organization_quotas_create.rb index 95c47189bbb..32cb9a1c666 100644 --- a/app/actions/organization_quotas_create.rb +++ b/app/actions/organization_quotas_create.rb @@ -16,6 +16,7 @@ def create(message) QuotaDefinition.db.transaction do org_quota = VCAP::CloudController::QuotaDefinition.create( name: message.name, + total_service_accounts: message.total_service_accounts || QuotaDefinition::UNLIMITED, # Apps memory_limit: message.total_memory_in_mb || QuotaDefinition::DEFAULT_MEMORY_LIMIT, diff --git a/app/actions/organization_quotas_update.rb b/app/actions/organization_quotas_update.rb index 1ead4b47209..1f8bb4f785f 100644 --- a/app/actions/organization_quotas_update.rb +++ b/app/actions/organization_quotas_update.rb @@ -18,6 +18,7 @@ def self.update(quota, message, user_audit_info) quota.lock! quota.name = message.name if message.requested? :name + quota.total_service_accounts = message.total_service_accounts || QuotaDefinition::UNLIMITED if message.service_accounts_limits_message.requested?(:total_service_accounts) quota.memory_limit = memory_limit(message) if message.apps_limits_message.requested? :total_memory_in_mb quota.instance_memory_limit = instance_memory_limit(message) if message.apps_limits_message.requested? :per_process_memory_in_mb diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index d4fe4d9b239..f86760831ec 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -56,6 +56,8 @@ def create account = nil ServiceAccountModel.db.transaction do space.organization.lock! + limit = space.organization.quota_definition.total_service_accounts + unprocessable!('Organization service account quota exceeded') if limit != -1 && ServiceAccountModel.where(organization_id: space.organization_id).count >= limit ServiceAccountCreationBudget.consume(current_user, exempt: permission_queryer.can_write_globally?) do if reuse lifecycle_conflict!('live account name is already in use') if ServiceAccountModel.where(organization_id: space.organization_id, name: message.name).any? diff --git a/app/messages/organization_quotas_update_message.rb b/app/messages/organization_quotas_update_message.rb index d111bf733a3..df499883af8 100644 --- a/app/messages/organization_quotas_update_message.rb +++ b/app/messages/organization_quotas_update_message.rb @@ -12,7 +12,7 @@ def self.key_requested?(key) proc { |a| a.requested?(key) } end - register_allowed_keys %i[name apps services routes domains] + register_allowed_keys %i[name apps services routes domains service_accounts] validates_with NoAdditionalKeysValidator validates :name, @@ -24,6 +24,20 @@ def self.key_requested?(key) validate :services_validator, if: key_requested?(:services) validate :routes_validator, if: key_requested?(:routes) validate :domains_validator, if: key_requested?(:domains) + validate :service_accounts_validator, if: key_requested?(:service_accounts) + + def service_accounts_limits_message + @service_accounts_limits_message ||= ServiceAccountsLimitsMessage.new(service_accounts&.deep_symbolize_keys) + end + + delegate :total_service_accounts, to: :service_accounts_limits_message + + def service_accounts_validator + return unless validates_hash(service_accounts, :service_accounts) + return if service_accounts_limits_message.valid? + + service_accounts_limits_message.errors.full_messages.each { |message| errors.add(:service_accounts, message:) } + end # Apps validations delegate :total_memory_in_mb, :per_process_memory_in_mb, :total_instances, :per_app_tasks, :log_rate_limit_in_bytes_per_second, to: :apps_limits_message @@ -107,4 +121,10 @@ class DomainsLimitsMessage < BaseMessage numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true end + + class ServiceAccountsLimitsMessage < BaseMessage + register_allowed_keys [:total_service_accounts] + validates_with NoAdditionalKeysValidator + validates :total_service_accounts, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true + end end diff --git a/app/presenters/v3/organization_quota_presenter.rb b/app/presenters/v3/organization_quota_presenter.rb index be8c131889b..00f66b96176 100644 --- a/app/presenters/v3/organization_quota_presenter.rb +++ b/app/presenters/v3/organization_quota_presenter.rb @@ -21,6 +21,7 @@ def to_hash created_at: organization_quota.created_at, updated_at: organization_quota.updated_at, name: organization_quota.name, + service_accounts: { total_service_accounts: convert_unlimited_to_nil(organization_quota.total_service_accounts) }, apps: { total_memory_in_mb: convert_unlimited_to_nil(organization_quota.memory_limit), per_process_memory_in_mb: convert_unlimited_to_nil(organization_quota.instance_memory_limit), diff --git a/db/migrations/20261109124000_add_org_service_account_quota.rb b/db/migrations/20261109124000_add_org_service_account_quota.rb new file mode 100644 index 00000000000..5b826aab8cb --- /dev/null +++ b/db/migrations/20261109124000_add_org_service_account_quota.rb @@ -0,0 +1,5 @@ +Sequel.migration do + change do + alter_table(:quota_definitions) { add_column :total_service_accounts, Integer, null: false, default: -1 } + end +end From 3a7a37b30170ef2e7519a428e424d220f882eaac Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:09:50 +0100 Subject: [PATCH 078/200] Test bearer account registration with required org attribution (red) --- ...rvice_account_registration_profile_spec.rb | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 spec/unit/actions/service_account_registration_profile_spec.rb diff --git a/spec/unit/actions/service_account_registration_profile_spec.rb b/spec/unit/actions/service_account_registration_profile_spec.rb new file mode 100644 index 00000000000..b2ec5d7775d --- /dev/null +++ b/spec/unit/actions/service_account_registration_profile_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' +require 'actions/service_account_provision' + +module VCAP::CloudController + RSpec.describe 'Service account UAA attribution profile' do + it 'registers an explicitly bearer account restricted to its owning org with caller attribution' do + org = create(:organization, service_account_prefix: 'payments-team') + account = ServiceAccountModel.create(name: 'payments-worker', space: create(:space, organization: org)) + clients = instance_double(CF::UAA::Scim) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + + ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity').provision(account) + + expect(clients).to have_received(:add).with(:client, hash_including( + 'tls_client_certificate_bound_access_tokens' => false, + 'tls-client-auth-required-claims' => { 'org_guid' => org.guid }, + 'tls-client-auth-claim-mappings' => [ + { 'field' => 'subject_cn', 'claim' => 'cf_instance_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^app:(.+)$', 'claim' => 'app_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^space:(.+)$', 'claim' => 'space_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^organization:(.+)$', 'claim' => 'org_guid' } + ] + )) + end + end +end From 08845a564580ccdb37879b4d8164dca4e988fb96 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:13:22 +0100 Subject: [PATCH 079/200] Register bearer account clients with owning org and workload attribution --- app/actions/service_account_provision.rb | 16 ++++++++++++++-- .../actions/service_account_provision_spec.rb | 10 +++++++++- .../service_account_registration_profile_spec.rb | 5 +++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index c4ad8e6e125..c43383c9f27 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -79,14 +79,14 @@ def deprovision(account, delete: false) private def matching_registration?(existing, desired) - tls_keys = existing.keys.select { |key| key.start_with?('tls-client-auth-', 'tls_client_auth_') } + tls_keys = existing.keys.grep(/\Atls[-_]/) 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) actual = [] if key == 'scope' && actual == ['uaa.none'] - if value.is_a?(Array) + if string_array?(value) actual.is_a?(Array) && actual.all? { |entry| entry.is_a?(String) } && actual.sort == value.sort else actual == value @@ -94,6 +94,10 @@ def matching_registration?(existing, desired) end end + def string_array?(value) + value.is_a?(Array) && value.all? { |entry| entry.is_a?(String) } + end + def existing_client(client_id) @clients.get(:client, client_id) rescue CF::UAA::NotFound @@ -109,6 +113,14 @@ def registration(account) 'access_token_validity' => 300, 'tls-client-auth-ca-ref' => @identity_ca_ref, 'tls_client_auth_san_dns' => account.certificate_dns_san, + 'tls_client_certificate_bound_access_tokens' => false, + 'tls-client-auth-claim-mappings' => [ + { 'field' => 'subject_cn', 'claim' => 'cf_instance_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^app:(.+)$', 'claim' => 'app_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^space:(.+)$', 'claim' => 'space_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^organization:(.+)$', 'claim' => 'org_guid' } + ], + 'tls-client-auth-required-claims' => { 'org_guid' => account.organization.guid }, 'cf_service_account_guid' => account.guid } end diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index 03cd1411035..bc7e736931f 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -15,6 +15,14 @@ module VCAP::CloudController 'access_token_validity' => 300, 'tls-client-auth-ca-ref' => 'diego-instance-identity', 'tls_client_auth_san_dns' => account.certificate_dns_san, + 'tls_client_certificate_bound_access_tokens' => false, + 'tls-client-auth-required-claims' => { 'org_guid' => account.organization.guid }, + 'tls-client-auth-claim-mappings' => [ + { 'field' => 'subject_cn', 'claim' => 'cf_instance_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^app:(.+)$', 'claim' => 'app_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^space:(.+)$', 'claim' => 'space_guid' }, + { 'field' => 'subject_ou', 'pattern' => '^organization:(.+)$', 'claim' => 'org_guid' } + ], 'cf_service_account_guid' => account.guid } end @@ -127,7 +135,7 @@ module VCAP::CloudController 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' }). + with(headers: { 'Authorization' => 'bearer test-token' }) { |request| Oj.load(request.body) == registration }. to_return(status: 201, headers: response_headers, body: registration.to_json) action.provision(account) diff --git a/spec/unit/actions/service_account_registration_profile_spec.rb b/spec/unit/actions/service_account_registration_profile_spec.rb index b2ec5d7775d..f86283e3b9b 100644 --- a/spec/unit/actions/service_account_registration_profile_spec.rb +++ b/spec/unit/actions/service_account_registration_profile_spec.rb @@ -12,7 +12,7 @@ module VCAP::CloudController ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity').provision(account) - expect(clients).to have_received(:add).with(:client, hash_including( + profile = { 'tls_client_certificate_bound_access_tokens' => false, 'tls-client-auth-required-claims' => { 'org_guid' => org.guid }, 'tls-client-auth-claim-mappings' => [ @@ -21,7 +21,8 @@ module VCAP::CloudController { 'field' => 'subject_ou', 'pattern' => '^space:(.+)$', 'claim' => 'space_guid' }, { 'field' => 'subject_ou', 'pattern' => '^organization:(.+)$', 'claim' => 'org_guid' } ] - )) + } + expect(clients).to have_received(:add).with(:client, hash_including(profile)) end end end From 3bde1d3ca36aa3d3efcfab0752b54587825c20f2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:16:13 +0100 Subject: [PATCH 080/200] Test prefix-qualified runtime identity across entitled spaces (red) --- ...qualified_service_account_identity_spec.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb diff --git a/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb new file mode 100644 index 00000000000..a2dec98711e --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' +require 'cloud_controller/diego/service_account_identity' + +module VCAP::CloudController::Diego + RSpec.describe 'Qualified runtime account identity' do + it 'encodes the immutable prefix for an entitled app in another space' do + org = create(:organization, service_account_prefix: 'payments-team') + source = create(:space, organization: org) + target = create(:space, organization: org) + account = VCAP::CloudController::ServiceAccountModel.create(name: 'worker-app', space: source, status: 'ready') + account.add_entitled_space(target) + app = create(:app_model, space: target, service_account: account) + TestConfig.override(service_account_runtime_enabled: true) + + properties = ServiceAccountIdentity.new(app, TestConfig.config_instance).certificate_properties + expect(properties.service_account.to_h).to include(name: 'worker-app', prefix: 'payments-team') + end + end +end From 3cdf836f5d70101cc98afc71bb19efdb03372450 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:17:28 +0100 Subject: [PATCH 081/200] Send qualified account identity for apps in entitled spaces --- lib/cloud_controller/diego/service_account_identity.rb | 4 ++-- lib/diego/bbs/models/certificate_properties_pb.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb index 324584f52a4..d2d41abf83d 100644 --- a/lib/cloud_controller/diego/service_account_identity.rb +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -12,7 +12,7 @@ def initialize(app, config, account_guid: app.service_account_guid) 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 + attributes[:service_account] = ::Diego::Bbs::Models::ServiceAccount.new(name: account.name, prefix: account.identity_prefix.to_s) if account ::Diego::Bbs::Models::CertificateProperties.new(attributes) end @@ -48,7 +48,7 @@ def ready_account end account = ServiceAccountModel.first(guid: @account_guid) - unless account && account.enabled && account.status == 'ready' && account.space_guid == @app.space_guid + unless account && account.enabled && account.status == 'ready' && account.entitled_to?(@app.space) raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account is not ready for runtime credentials') end diff --git a/lib/diego/bbs/models/certificate_properties_pb.rb b/lib/diego/bbs/models/certificate_properties_pb.rb index 16355dfbcd3..3a327cdd67d 100644 --- a/lib/diego/bbs/models/certificate_properties_pb.rb +++ b/lib/diego/bbs/models/certificate_properties_pb.rb @@ -5,7 +5,7 @@ require 'google/protobuf' -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" +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\".\n\x0eServiceAccount\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06prefix\x18\x02 \x01(\tb\x06proto3" pool = ::Google::Protobuf::DescriptorPool.generated_pool pool.add_serialized_file(descriptor_data) From 7bcd1a45b8ec5b26cc25026aab310f333e1ebd61 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:19:57 +0100 Subject: [PATCH 082/200] Test platform service account context on broker app bindings (red) --- .../lib/services/service_brokers/v2/client_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/unit/lib/services/service_brokers/v2/client_spec.rb b/spec/unit/lib/services/service_brokers/v2/client_spec.rb index 780848c10d4..bea143b3103 100644 --- a/spec/unit/lib/services/service_brokers/v2/client_spec.rb +++ b/spec/unit/lib/services/service_brokers/v2/client_spec.rb @@ -153,6 +153,7 @@ module VCAP::Services::ServiceBrokers::V2 with(path, anything, { user_guid: nil }) end + context 'when the caller passes the accepts_incomplete flag' do let(:path) { "/v2/service_instances/#{instance.guid}?accepts_incomplete=true" } @@ -1410,6 +1411,17 @@ module VCAP::Services::ServiceBrokers::V2 end describe '#bind' do + it 'hands the ready assigned account issuer and subject to the broker in platform context' do + app.space.organization.update(service_account_prefix: 'broker-team') + account = VCAP::CloudController::ServiceAccountModel.create(name: 'broker-worker', space: app.space, status: 'ready') + app.update(service_account: account) + + client.bind(binding) + + context = hash_including(service_account: { issuer: VCAP::CloudController::UAAIssuer::ISSUER, subject: account.client_id }) + expect(http_client).to have_received(:put).with(anything, hash_including(context: context), anything) + end + let(:instance) { create(:managed_service_instance) } let(:app) { create(:app_model, space: instance.space) } let(:binding) do From 20efc9bfc2a1fe68672b68bd116c551839113f0c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:21:12 +0100 Subject: [PATCH 083/200] Correct broker context fixture and confirm missing identity context (red) --- spec/unit/lib/services/service_brokers/v2/client_spec.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/spec/unit/lib/services/service_brokers/v2/client_spec.rb b/spec/unit/lib/services/service_brokers/v2/client_spec.rb index bea143b3103..d29f4e63548 100644 --- a/spec/unit/lib/services/service_brokers/v2/client_spec.rb +++ b/spec/unit/lib/services/service_brokers/v2/client_spec.rb @@ -152,8 +152,6 @@ module VCAP::Services::ServiceBrokers::V2 expect(http_client).to have_received(:put). with(path, anything, { user_guid: nil }) end - - context 'when the caller passes the accepts_incomplete flag' do let(:path) { "/v2/service_instances/#{instance.guid}?accepts_incomplete=true" } @@ -1418,7 +1416,7 @@ module VCAP::Services::ServiceBrokers::V2 client.bind(binding) - context = hash_including(service_account: { issuer: VCAP::CloudController::UAAIssuer::ISSUER, subject: account.client_id }) + context = hash_including(service_account: { issuer: UAAIssuer::ISSUER, subject: account.client_id }) expect(http_client).to have_received(:put).with(anything, hash_including(context: context), anything) end From 047ca8f4d9a99a99124d62ea6e1b9caf2d0adaa6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:23:48 +0100 Subject: [PATCH 084/200] Include platform-controlled account identity in broker binding context --- lib/services/service_brokers/v2/client.rb | 14 +++++++++++ .../service_brokers/v2/client_spec.rb | 23 ++++++++++--------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/lib/services/service_brokers/v2/client.rb b/lib/services/service_brokers/v2/client.rb index e80cf656810..cb268803175 100644 --- a/lib/services/service_brokers/v2/client.rb +++ b/lib/services/service_brokers/v2/client.rb @@ -118,6 +118,8 @@ def bind(binding, arbitrary_parameters: {}, accepts_incomplete: false, user_guid body = body.compact body[:parameters] = arbitrary_parameters if arbitrary_parameters.present? + add_service_account_context(body, binding) + begin response = @http_client.put(path, body, user_guid:) rescue Errors::HttpClientTimeout => e @@ -367,6 +369,18 @@ def context_hash(service_instance) } end + def add_service_account_context(body, binding) + account = binding.respond_to?(:app) ? binding.app&.service_account : nil + return unless account + + unless account.enabled && account.status == 'ready' && account.entitled_to?(binding.app.space) + raise VCAP::CloudController::ServiceAccountProvision::Conflict.new('service account is not ready for a broker binding') + end + + issuer = VCAP::CloudController::UaaTokenDecoder.new(VCAP::CloudController::Config.config.get(:uaa)).send(:uaa_issuer) + body[:context][:service_account] = { issuer: issuer, subject: account.client_id } + end + def async_response?(response) response.code == 202 end diff --git a/spec/unit/lib/services/service_brokers/v2/client_spec.rb b/spec/unit/lib/services/service_brokers/v2/client_spec.rb index d29f4e63548..a7db191b42b 100644 --- a/spec/unit/lib/services/service_brokers/v2/client_spec.rb +++ b/spec/unit/lib/services/service_brokers/v2/client_spec.rb @@ -152,6 +152,7 @@ module VCAP::Services::ServiceBrokers::V2 expect(http_client).to have_received(:put). with(path, anything, { user_guid: nil }) end + context 'when the caller passes the accepts_incomplete flag' do let(:path) { "/v2/service_instances/#{instance.guid}?accepts_incomplete=true" } @@ -1409,17 +1410,6 @@ module VCAP::Services::ServiceBrokers::V2 end describe '#bind' do - it 'hands the ready assigned account issuer and subject to the broker in platform context' do - app.space.organization.update(service_account_prefix: 'broker-team') - account = VCAP::CloudController::ServiceAccountModel.create(name: 'broker-worker', space: app.space, status: 'ready') - app.update(service_account: account) - - client.bind(binding) - - context = hash_including(service_account: { issuer: UAAIssuer::ISSUER, subject: account.client_id }) - expect(http_client).to have_received(:put).with(anything, hash_including(context: context), anything) - end - let(:instance) { create(:managed_service_instance) } let(:app) { create(:app_model, space: instance.space) } let(:binding) do @@ -1448,6 +1438,17 @@ module VCAP::Services::ServiceBrokers::V2 allow(http_client).to receive(:put).and_return(response) end + it 'hands the ready assigned account issuer and subject to the broker in platform context' do + app.space.organization.update(service_account_prefix: 'broker-team') + account = VCAP::CloudController::ServiceAccountModel.create(name: 'broker-worker', space: app.space, status: 'ready') + app.update(service_account: account) + + client.bind(binding) + + context = hash_including(service_account: { issuer: UAAIssuer::ISSUER, subject: account.client_id }) + expect(http_client).to have_received(:put).with(anything, hash_including(context: context), anything) + end + it 'makes a put request with correct path' do client.bind(binding) From 76275d8116d053132a0fd0fe8608a66099c0b768 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:24:56 +0100 Subject: [PATCH 085/200] Test qualified service account route sources and grant cleanup (red) --- .../service_account_route_policies_spec.rb | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 spec/request/service_account_route_policies_spec.rb diff --git a/spec/request/service_account_route_policies_spec.rb b/spec/request/service_account_route_policies_spec.rb new file mode 100644 index 00000000000..4cb553bd1bd --- /dev/null +++ b/spec/request/service_account_route_policies_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Service account route sources' do + it 'resolves a qualified source to an immutable account reference and removes grants on deletion' do + org = create(:organization, service_account_prefix: 'route-team') + space = create(:space, organization: org) + account = ServiceAccountModel.create(name: 'route-worker', space: space) + domain = create(:private_domain, owning_organization: org, enforce_route_policies: true, route_policies_scope: 'space') + route = create(:route, space: space, domain: domain) + headers = admin_headers_for(create(:user)) + body = { source: 'cf:svc:route-team:route-worker', relationships: { route: { data: { guid: route.guid } } } } + + post '/v3/route_policies', body.to_json, headers + + expect(last_response.status).to eq(201) + expect(parsed_response['source']).to eq(body[:source]) + policy = RoutePolicy.first(guid: parsed_response['guid']) + expect(policy.source_guid).to eq(account.guid) + account.destroy + expect(RoutePolicy.first(guid: policy.guid)).to be_nil + end + end +end From 6a86ce72a9568cb9fc6678cd342972b78b8746ba Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:26:07 +0100 Subject: [PATCH 086/200] Resolve qualified account route sources and remove their grants on deletion --- app/messages/route_policy_create_message.rb | 1 + app/models/runtime/route_policy.rb | 12 +++++++++++- app/models/runtime/service_account_model.rb | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/messages/route_policy_create_message.rb b/app/messages/route_policy_create_message.rb index fa7edb57c94..0dba5cfbcbe 100644 --- a/app/messages/route_policy_create_message.rb +++ b/app/messages/route_policy_create_message.rb @@ -26,6 +26,7 @@ def relationships_message def source_format_valid return unless source.is_a?(String) return if RoutePolicy::SOURCE_REGEX.match?(source) + return if RoutePolicy::SERVICE_ACCOUNT_SOURCE_REGEX.match?(source) errors.add(:source, "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'") end diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb index 87f63a70ebb..eb8eb332091 100644 --- a/app/models/runtime/route_policy.rb +++ b/app/models/runtime/route_policy.rb @@ -1,6 +1,7 @@ module VCAP::CloudController class RoutePolicy < Sequel::Model(:route_policies) SOURCE_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/ + SERVICE_ACCOUNT_SOURCE_REGEX = /\Acf:svc:([a-z0-9][a-z0-9-]{1,61}[a-z0-9]):([a-z0-9][a-z0-9-]{1,61}[a-z0-9])\z/ many_to_one :route, class: 'VCAP::CloudController::Route', @@ -14,13 +15,21 @@ class RoutePolicy < Sequel::Model(:route_policies) add_association_dependencies annotations: :destroy def source + if source_type == 'svc' + account = ServiceAccountModel.first(guid: source_guid) + return "cf:svc:#{account.identity_prefix}:#{account.name}" if account + end source_guid.to_s.empty? ? 'cf:any' : "cf:#{source_type}:#{source_guid}" end def source=(val) return if val.nil? - if val == 'cf:any' + if (identity = val.match(SERVICE_ACCOUNT_SOURCE_REGEX)) + account = ServiceAccountModel.first(identity_prefix: identity[1], name: identity[2]) + self.source_type = 'svc' + self.source_guid = account&.guid + elsif val == 'cf:any' self.source_type = 'any' self.source_guid = '' else @@ -33,6 +42,7 @@ def source=(val) def validate validates_presence :source_type validates_presence :route_id + errors.add(:source, 'service account not found') if source_type == 'svc' && source_guid.nil? validate_cf_any_exclusivity end diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index c5a4f1ef4e3..d359b191a7d 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -79,6 +79,10 @@ def certificate_dns_san end def before_destroy + RoutePolicy.where(source_type: 'svc', source_guid: guid).all.each do |policy| + policy.destroy + policy.notify_diego + end db[:service_account_names].where(name: name, organization_id: organization_id).update(deleted_at: Sequel::CURRENT_TIMESTAMP) super end From f4d20d24f9da59b2d128e9988f284e01ae595e91 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:28:51 +0100 Subject: [PATCH 087/200] Test account reassignment guard for broker bindings (red) --- .../service_account_binding_guard_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 spec/unit/actions/service_account_binding_guard_spec.rb diff --git a/spec/unit/actions/service_account_binding_guard_spec.rb b/spec/unit/actions/service_account_binding_guard_spec.rb new file mode 100644 index 00000000000..79771379b3e --- /dev/null +++ b/spec/unit/actions/service_account_binding_guard_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' +require 'actions/app_assign_service_account' + +module VCAP::CloudController + RSpec.describe 'Service account identity binding guard' do + it 'refuses to unassign an account while a broker binding depends on the identity' do + space = create(:space) + account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') + app = create(:app_model, space: space, service_account: account) + create(:service_binding, app: app, service_instance: create(:managed_service_instance, space: space)) + permissions = double(can_write_to_active_space?: true) + + expect { AppAssignServiceAccount.new(permissions).assign(app, nil) }.to raise_error(AppAssignServiceAccount::Conflict, /bindings/) + expect(app.reload.service_account_guid).to eq(account.guid) + end + end +end From 30b6a5fbd1386b8b31efa24810f68f15b06201c2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:31:06 +0100 Subject: [PATCH 088/200] Block account reassignment while broker bindings remain --- app/actions/app_assign_service_account.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 70542439ded..b51154d22c5 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -17,6 +17,8 @@ def assign(app, account, provision: false) app.lock! raise Unauthorized.new('not authorized to update the app') unless @permissions.can_write_to_active_space?(app.space.id) + validate_binding_change!(app, account) + if account account.lock! raise Conflict.new('account must be entitled to the app space in the same organization') unless account.entitled_to?(app.space) @@ -38,6 +40,12 @@ def assign(app, account, provision: false) private + def validate_binding_change!(app, account) + return unless app.service_account_guid && app.service_account_guid != account&.guid && app.service_bindings_dataset.any? + + raise Conflict.new('remove service bindings before changing the service account') + end + def record_assignment(app, account, previous_guid) Repositories::ServiceAccountEventRepository.record( app, account ? 'assign' : 'unassign', @actor, From 5055bb1b9b6f6be9f6c01fe45348cd56170c6dab Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:32:59 +0100 Subject: [PATCH 089/200] Test prefix and writable-space entitlement authorization guards (red) --- .../request/service_account_entitlements_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index 4d900535ba7..ebf3770321b 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -84,5 +84,21 @@ def entitle(destination, headers) expect(last_response.status).to eq(201) expect(ServiceAccountModel.first(guid: parsed_response['guid']).entitled_spaces).to contain_exactly(space) end + + it 'refuses entitlements for an org with no allocated prefix' do + legacy_space = create(:space) + legacy = ServiceAccountModel.create(name: 'legacy-worker', space: legacy_space) + destination = create(:space, organization: legacy_space.organization) + post "/v3/service_accounts/#{legacy.guid}/relationships/spaces", { data: [{ guid: destination.guid }] }.to_json, admin_headers_for(user) + expect(last_response.status).to eq(422) + end + + it 'does not let a developer in a suspended entitled space manage the account' do + headers = auth('space_developer') + account + space.update(status: Space::SUSPENDED) + patch "/v3/service_accounts/#{account.guid}", { description: 'blocked' }.to_json, headers + expect(last_response.status).to eq(403) + end end end From c999e95678e4a2ac414cb5da0b6eda0fa1c36b62 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:34:36 +0100 Subject: [PATCH 090/200] Require allocated prefixes and active developer spaces for account management --- app/controllers/v3/service_accounts_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index f86760831ec..5f25e363365 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -132,7 +132,7 @@ def developer?(space) end def authorize_account_management!(account) - unauthorized! unless org_manager?(account) || account.entitled_spaces.any? { |space| developer?(space) } + unauthorized! unless org_manager?(account) || account.entitled_spaces.any? { |space| developer?(space) && space.active? } require_writable_org!(account.organization) end @@ -145,6 +145,7 @@ def spaces def entitle account = readable_account + unprocessable!('Organization service account prefix is unset') unless account.organization.service_account_prefix data = entitlement_guids account.db.transaction do account.lock! From e4042d017bcb395f09273e123d7041ff26908f3e Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:35:32 +0100 Subject: [PATCH 091/200] Test readable account route sources and qualified filtering (red) --- .../service_account_route_policies_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/request/service_account_route_policies_spec.rb b/spec/request/service_account_route_policies_spec.rb index 4cb553bd1bd..075cd8ccf28 100644 --- a/spec/request/service_account_route_policies_spec.rb +++ b/spec/request/service_account_route_policies_spec.rb @@ -2,6 +2,23 @@ module VCAP::CloudController RSpec.describe 'Service account route sources' do + it 'rejects granting an unreadable account and filters qualified sources without parser failures' do + org = create(:organization, service_account_prefix: 'route-team') + space = create(:space, organization: org) + hidden_space = create(:space, organization: create(:organization, service_account_prefix: 'hidden-team')) + ServiceAccountModel.create(name: 'hidden-worker', space: hidden_space) + domain = create(:private_domain, owning_organization: org, enforce_route_policies: true) + route = create(:route, space: space, domain: domain) + user = create(:user) + headers = set_user_with_header_as_role(role: 'space_developer', org: org, space: space, user: user) + body = { source: 'cf:svc:hidden-team:hidden-worker', relationships: { route: { data: { guid: route.guid } } } } + post '/v3/route_policies', body.to_json, headers + expect(last_response.status).to eq(422) + get '/v3/route_policies?sources=cf:svc:route-team:missing-worker', nil, admin_headers_for(user) + expect(last_response.status).to eq(200) + expect(parsed_response['resources']).to eq([]) + end + it 'resolves a qualified source to an immutable account reference and removes grants on deletion' do org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) From a44abb91765589ff62f916cdcc74abeede1a5872 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:38:20 +0100 Subject: [PATCH 092/200] Authorize account route grants and support qualified source filters --- .../v3/route_policies_controller.rb | 33 ++++++++++++++----- app/messages/route_policies_list_message.rb | 2 +- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 0fe23c7b67f..fc9bbbeb726 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -53,6 +53,7 @@ def create route = find_and_authorize_route(message.route_guid) validate_route_domain(route) + validate_account_source!(message.source) route_policy = VCAP::CloudController::RoutePolicyCreate.new(user_audit_info).create(route: route, message: message) @@ -88,6 +89,17 @@ def destroy private + def validate_account_source!(source) + match = source.match(RoutePolicy::SERVICE_ACCOUNT_SOURCE_REGEX) + return unless match + + account = ServiceAccountModel.first(identity_prefix: match[1], name: match[2]) + readable = account && (permission_queryer.can_read_globally? || + account.organization.managers_dataset.where(users__id: current_user.id).any? || + account.entitled_spaces.any? { |space| permission_queryer.can_read_from_space?(space.id, space.organization_id) }) + unprocessable!('Service account not found or not readable') unless readable + end + def find_route_policy_with_route_and_space(guid) VCAP::CloudController::RoutePolicy.eager_graph(route: :space).where(Sequel[:route_policies][:guid] => guid).all[0] end @@ -142,14 +154,7 @@ def build_dataset(message) dataset = dataset.where(guid: message.guids) if message.requested?(:guids) if message.requested?(:sources) - conditions = message.sources.map do |src| - if src == 'cf:any' - Sequel.&(source_type: 'any', source_guid: '') - else - m = src.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) - Sequel.&(source_type: m[1], source_guid: m[2]) - end - end + conditions = message.sources.map { |source| source_condition(source) } dataset = dataset.where(Sequel.|(*conditions)) end @@ -166,4 +171,16 @@ def build_dataset(message) dataset end + + def source_condition(source) + return Sequel.&(source_type: 'any', source_guid: '') if source == 'cf:any' + + if (identity = source.match(RoutePolicy::SERVICE_ACCOUNT_SOURCE_REGEX)) + account = ServiceAccountModel.first(identity_prefix: identity[1], name: identity[2]) + Sequel.&(source_type: 'svc', source_guid: account&.guid) + else + match = source.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) + Sequel.&(source_type: match[1], source_guid: match[2]) + end + end end diff --git a/app/messages/route_policies_list_message.rb b/app/messages/route_policies_list_message.rb index 6e380806aaf..96fe95fc9ee 100644 --- a/app/messages/route_policies_list_message.rb +++ b/app/messages/route_policies_list_message.rb @@ -30,7 +30,7 @@ def self.from_params(params) def sources_format_valid return unless sources.is_a?(Array) - invalid = sources.reject { |s| s.is_a?(String) && RoutePolicy::SOURCE_REGEX.match?(s) } + invalid = sources.reject { |s| s.is_a?(String) && (RoutePolicy::SOURCE_REGEX.match?(s) || RoutePolicy::SERVICE_ACCOUNT_SOURCE_REGEX.match?(s)) } return if invalid.empty? errors.add(:sources, "contains invalid source format: #{invalid.join(', ')}") From 6fe38b1a681bb77a6149129e20c948a2e3746cef Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:40:39 +0100 Subject: [PATCH 093/200] Test qualified runtime contract gate fails closed by default (red) --- .../diego/qualified_service_account_identity_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb index a2dec98711e..5e0ddf53fed 100644 --- a/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb @@ -15,5 +15,15 @@ module VCAP::CloudController::Diego properties = ServiceAccountIdentity.new(app, TestConfig.config_instance).certificate_properties expect(properties.service_account.to_h).to include(name: 'worker-app', prefix: 'payments-team') end + + it 'fails closed for qualified identity until the operator enables contract version 2' do + org = create(:organization, service_account_prefix: 'payments-team') + space = create(:space, organization: org) + account = VCAP::CloudController::ServiceAccountModel.create(name: 'worker-app', space: space, status: 'ready') + app = create(:app_model, space: space, service_account: account) + TestConfig.override(service_account_runtime_enabled: true) + expect { ServiceAccountIdentity.new(app, TestConfig.config_instance).certificate_properties }. + to raise_error(CloudController::Errors::ApiError, /qualified.*contract/) + end end end From 25292b7588ed08ced23d4a09375446de450bb377 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:42:21 +0100 Subject: [PATCH 094/200] Gate qualified runtime identities on explicit contract version two --- lib/cloud_controller/config_schemas/api_schema.rb | 1 + lib/cloud_controller/config_schemas/clock_schema.rb | 1 + lib/cloud_controller/config_schemas/worker_schema.rb | 1 + lib/cloud_controller/diego/service_account_identity.rb | 3 +++ .../diego/qualified_service_account_identity_spec.rb | 2 +- 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index f9ab51841df..78d63e2a0e9 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_runtime_contract_version) => Integer, optional(:service_account_creation_limit) => Integer, optional(:service_account_token_endpoint) => String, optional(:service_account_provisioning) => { diff --git a/lib/cloud_controller/config_schemas/clock_schema.rb b/lib/cloud_controller/config_schemas/clock_schema.rb index 7793c0160ae..a3534119335 100644 --- a/lib/cloud_controller/config_schemas/clock_schema.rb +++ b/lib/cloud_controller/config_schemas/clock_schema.rb @@ -7,6 +7,7 @@ class ClockSchema < VCAP::Config define_schema do { optional(:service_account_runtime_enabled) => bool, + optional(:service_account_runtime_contract_version) => Integer, optional(:service_account_token_endpoint) => String, external_port: Integer, external_domain: String, diff --git a/lib/cloud_controller/config_schemas/worker_schema.rb b/lib/cloud_controller/config_schemas/worker_schema.rb index 332c659f253..fb96053bd57 100644 --- a/lib/cloud_controller/config_schemas/worker_schema.rb +++ b/lib/cloud_controller/config_schemas/worker_schema.rb @@ -35,6 +35,7 @@ class WorkerSchema < VCAP::Config identity_ca_ref: String }, optional(:service_account_runtime_enabled) => bool, + optional(:service_account_runtime_contract_version) => Integer, optional(:service_account_token_endpoint) => String, logging: { diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb index d2d41abf83d..67ac881df50 100644 --- a/lib/cloud_controller/diego/service_account_identity.rb +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -48,6 +48,9 @@ def ready_account end account = ServiceAccountModel.first(guid: @account_guid) + if account&.identity_prefix && @config.get(:service_account_runtime_contract_version) != 2 + raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account qualified runtime contract version 2 is not enabled') + end unless account && account.enabled && account.status == 'ready' && account.entitled_to?(@app.space) raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account is not ready for runtime credentials') end diff --git a/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb index 5e0ddf53fed..bdadf746851 100644 --- a/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb @@ -10,7 +10,7 @@ module VCAP::CloudController::Diego account = VCAP::CloudController::ServiceAccountModel.create(name: 'worker-app', space: source, status: 'ready') account.add_entitled_space(target) app = create(:app_model, space: target, service_account: account) - TestConfig.override(service_account_runtime_enabled: true) + TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2) properties = ServiceAccountIdentity.new(app, TestConfig.config_instance).certificate_properties expect(properties.service_account.to_h).to include(name: 'worker-app', prefix: 'payments-team') From 8fdaf6486957d47eddd79536895fb1bd4ab5ea9f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:43:24 +0100 Subject: [PATCH 095/200] Test durable deletion history across explicit account name reuse (red) --- spec/request/service_account_namespaces_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/request/service_account_namespaces_spec.rb b/spec/request/service_account_namespaces_spec.rb index c45eff96902..a179ef8e57a 100644 --- a/spec/request/service_account_namespaces_spec.rb +++ b/spec/request/service_account_namespaces_spec.rb @@ -45,5 +45,15 @@ def auth(role='space_developer') expect(parsed_response['status']).to eq('reserved') expect(Event.first(type: 'audit.service_account.create', actee: parsed_response['guid']).metadata).to include('reuse_name' => true) end + + it 'keeps deletion history when a tombstone is explicitly reused' do + post '/v3/service_accounts', body.to_json, auth + original = ServiceAccountModel.first(guid: parsed_response['guid']) + original.destroy + timestamp = ServiceAccountModel.db[:service_account_names].first(name: body[:name], organization_id: org.id)[:deleted_at] + post '/v3/service_accounts', body.merge(reuse_name: true).to_json, auth('org_manager') + expect(last_response.status).to eq(201) + expect(ServiceAccountModel.db[:service_account_name_deletions].first(account_guid: original.guid)).to include(deleted_at: timestamp) + end end end From 2be0f15220488096272c2bea84442e19f238559f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:45:46 +0100 Subject: [PATCH 096/200] Retain account deletion timestamps independently of reusable reservations --- app/models/runtime/service_account_model.rb | 5 +++++ ...5000_create_service_account_deletion_history.rb | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 db/migrations/20261109125000_create_service_account_deletion_history.rb diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index d359b191a7d..137203d8076 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -84,6 +84,11 @@ def before_destroy policy.notify_diego end db[:service_account_names].where(name: name, organization_id: organization_id).update(deleted_at: Sequel::CURRENT_TIMESTAMP) + # Durable history is independent of audit-event retention and reservation reuse. + # rubocop:disable Rails/SkipsModelValidations -- Sequel dataset history, not an ActiveRecord model. + history = { account_guid: guid, organization_guid: organization.guid, name: name, identity_prefix: identity_prefix, deleted_at: Sequel::CURRENT_TIMESTAMP } + db[:service_account_name_deletions].insert(history) + # rubocop:enable Rails/SkipsModelValidations super end end diff --git a/db/migrations/20261109125000_create_service_account_deletion_history.rb b/db/migrations/20261109125000_create_service_account_deletion_history.rb new file mode 100644 index 00000000000..2f9dfe86ead --- /dev/null +++ b/db/migrations/20261109125000_create_service_account_deletion_history.rb @@ -0,0 +1,14 @@ +Sequel.migration do + change do + create_table :service_account_name_deletions do + primary_key :id, name: :service_account_name_deletions_pkey + String :account_guid, size: 255, null: false + String :organization_guid, size: 255, null: false + String :name, size: 63, null: false + String :identity_prefix, size: 63 + DateTime :deleted_at, null: false + index :account_guid, unique: true, name: :service_account_deletions_account_unique + index :deleted_at, name: :service_account_deletions_time_index + end + end +end From 74980758ae0a2e858211990551757ca3e3858ca9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:46:56 +0100 Subject: [PATCH 097/200] Test qualified route account rules require an enforcing router contract (red) --- spec/request/service_account_route_policies_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/request/service_account_route_policies_spec.rb b/spec/request/service_account_route_policies_spec.rb index 075cd8ccf28..bb9a5ddec71 100644 --- a/spec/request/service_account_route_policies_spec.rb +++ b/spec/request/service_account_route_policies_spec.rb @@ -2,6 +2,18 @@ module VCAP::CloudController RSpec.describe 'Service account route sources' do + it 'refuses account rules until qualified routers are explicitly enabled' do + org = create(:organization, service_account_prefix: 'route-team') + space = create(:space, organization: org) + ServiceAccountModel.create(name: 'route-worker', space: space) + domain = create(:private_domain, owning_organization: org, enforce_route_policies: true) + route = create(:route, space: space, domain: domain) + body = { source: 'cf:svc:route-team:route-worker', relationships: { route: { data: { guid: route.guid } } } } + post '/v3/route_policies', body.to_json, admin_headers_for(create(:user)) + expect(last_response.status).to eq(422) + expect(parsed_response.dig('errors', 0, 'detail')).to include('router contract') + end + it 'rejects granting an unreadable account and filters qualified sources without parser failures' do org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) From db3962388068cb44cc05b622ab20c488de644db2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:48:30 +0100 Subject: [PATCH 098/200] Gate service account route rules on qualified router support --- app/controllers/v3/route_policies_controller.rb | 2 ++ lib/cloud_controller/config_schemas/api_schema.rb | 1 + spec/request/service_account_route_policies_spec.rb | 2 ++ 3 files changed, 5 insertions(+) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index fc9bbbeb726..1e2ed7756d2 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -93,6 +93,8 @@ def validate_account_source!(source) match = source.match(RoutePolicy::SERVICE_ACCOUNT_SOURCE_REGEX) return unless match + unprocessable!('Service account router contract version 2 is not enabled') unless Config.config.get(:service_account_router_contract_version) == 2 + account = ServiceAccountModel.first(identity_prefix: match[1], name: match[2]) readable = account && (permission_queryer.can_read_globally? || account.organization.managers_dataset.where(users__id: current_user.id).any? || diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index 78d63e2a0e9..a7af2f45ec5 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -33,6 +33,7 @@ class ApiSchema < VCAP::Config optional(:service_account_provisioning_enabled) => bool, optional(:service_account_runtime_enabled) => bool, optional(:service_account_runtime_contract_version) => Integer, + optional(:service_account_router_contract_version) => Integer, optional(:service_account_creation_limit) => Integer, optional(:service_account_token_endpoint) => String, optional(:service_account_provisioning) => { diff --git a/spec/request/service_account_route_policies_spec.rb b/spec/request/service_account_route_policies_spec.rb index bb9a5ddec71..ca25004888c 100644 --- a/spec/request/service_account_route_policies_spec.rb +++ b/spec/request/service_account_route_policies_spec.rb @@ -15,6 +15,7 @@ module VCAP::CloudController end it 'rejects granting an unreadable account and filters qualified sources without parser failures' do + TestConfig.override(service_account_router_contract_version: 2) org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) hidden_space = create(:space, organization: create(:organization, service_account_prefix: 'hidden-team')) @@ -32,6 +33,7 @@ module VCAP::CloudController end it 'resolves a qualified source to an immutable account reference and removes grants on deletion' do + TestConfig.override(service_account_router_contract_version: 2) org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) account = ServiceAccountModel.create(name: 'route-worker', space: space) From d02758d9d1177d4f47f285ae484009e3ec932810 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:50:55 +0100 Subject: [PATCH 099/200] Test concurrent account quota enforcement in the registry (red) --- .../service_account_quota_concurrency_spec.rb | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 spec/unit/actions/service_account_quota_concurrency_spec.rb diff --git a/spec/unit/actions/service_account_quota_concurrency_spec.rb b/spec/unit/actions/service_account_quota_concurrency_spec.rb new file mode 100644 index 00000000000..fedd418f7ab --- /dev/null +++ b/spec/unit/actions/service_account_quota_concurrency_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Service account quota concurrency' do + it 'serializes direct account creations within an org', isolation: :truncation do + org = create(:organization, service_account_prefix: 'race-team') + org.quota_definition.update(total_service_accounts: 1) + space = create(:space, organization: org) + gate = Queue.new + threads = 2.times.map do |index| + Thread.new do + gate.pop + ServiceAccountModel.create(name: "worker-#{index}", space: Space.first(guid: space.guid)) + :created + rescue Sequel::ValidationFailed + :quota_exceeded + end + end + threads.size.times { gate << true } + expect(threads.map(&:value)).to contain_exactly(:created, :quota_exceeded) + expect(ServiceAccountModel.where(organization_id: org.id).count).to eq(1) + end + end +end From cd03ff623d8a89ee36e0769c765f984d126c6ba0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:52:49 +0100 Subject: [PATCH 100/200] Serialize registry quota checks under the owning organization lock --- app/models/runtime/service_account_model.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 137203d8076..8aa954d407a 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -38,6 +38,13 @@ def validate_identity_immutability def around_create db.transaction(savepoint: true) do + organization.lock! + quota = organization.quota_definition + limit = quota.total_service_accounts + if limit != -1 && self.class.where(organization_id: organization_id).count >= limit + errors.add(:organization, 'service account quota exceeded') + raise validation_failed_error + end # 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, organization_id: organization_id) # rubocop:disable Rails/SkipsModelValidations -- Atomic namespace reservation, not an ActiveRecord model. From 6f3060edd95e5aa63c80dc0bfe2fdd4b94c36752 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:53:48 +0100 Subject: [PATCH 101/200] Test rejecting new assignments without an organization prefix (red) --- spec/request/service_account_entitlements_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index ebf3770321b..8b6891955b0 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -100,5 +100,14 @@ def entitle(destination, headers) patch "/v3/service_accounts/#{account.guid}", { description: 'blocked' }.to_json, headers expect(last_response.status).to eq(403) end + + it 'refuses a new assignment for a legacy account whose org prefix is unset' do + legacy_space = create(:space) + legacy = ServiceAccountModel.create(name: 'legacy-worker', space: legacy_space, status: 'ready') + app = create(:app_model, space: legacy_space) + patch "/v3/apps/#{app.guid}/relationships/service_account", { data: { guid: legacy.guid } }.to_json, admin_headers_for(user) + expect(last_response.status).to eq(409) + expect(app.reload.service_account_guid).to be_nil + end end end From e89fc24422a3ee087af8e4f9938651cc414185d0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:56:05 +0100 Subject: [PATCH 102/200] Require allocated organization prefixes for new account assignments --- app/actions/app_assign_service_account.rb | 1 + spec/request/app_service_accounts_spec.rb | 2 +- spec/unit/actions/app_assign_service_account_spec.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index b51154d22c5..300f4642282 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -21,6 +21,7 @@ def assign(app, account, provision: false) if account account.lock! + raise Conflict.new('organization service account prefix is unset') unless account.organization.service_account_prefix raise Conflict.new('account must be entitled to the app space in the same organization') unless account.entitled_to?(app.space) validate_ready!(account, provision) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index 0b92bbca46a..8a374b9ba5d 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' RSpec.describe 'App service account relationship' do - let(:space) { create(:space) } + let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'assignment-team')) } 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') } diff --git a/spec/unit/actions/app_assign_service_account_spec.rb b/spec/unit/actions/app_assign_service_account_spec.rb index d974bedce5f..84b6812a4d9 100644 --- a/spec/unit/actions/app_assign_service_account_spec.rb +++ b/spec/unit/actions/app_assign_service_account_spec.rb @@ -3,7 +3,7 @@ module VCAP::CloudController RSpec.describe 'Same-space service account assignment' do - let(:space) { create(:space) } + let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'assignment-team')) } 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) } From 471ca8e4030d2c85eb82165f19383eb125edfc32 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:57:27 +0100 Subject: [PATCH 103/200] Test named UAA resource policy registration (red) --- .../service_account_registration_profile_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/unit/actions/service_account_registration_profile_spec.rb b/spec/unit/actions/service_account_registration_profile_spec.rb index f86283e3b9b..931c8ffa35f 100644 --- a/spec/unit/actions/service_account_registration_profile_spec.rb +++ b/spec/unit/actions/service_account_registration_profile_spec.rb @@ -3,6 +3,16 @@ module VCAP::CloudController RSpec.describe 'Service account UAA attribution profile' do + it 'registers the operator named resource policy without copying audience lists' do + org = create(:organization, service_account_prefix: 'policy-team') + account = ServiceAccountModel.create(name: 'policy-worker', space: create(:space, organization: org)) + clients = instance_double(CF::UAA::Scim) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity', allowed_resources_ref: 'cf-workload-federation').provision(account) + expect(clients).to have_received(:add).with(:client, hash_including('tls-client-auth-allowed-resources-ref' => 'cf-workload-federation')) + end + it 'registers an explicitly bearer account restricted to its owning org with caller attribution' do org = create(:organization, service_account_prefix: 'payments-team') account = ServiceAccountModel.create(name: 'payments-worker', space: create(:space, organization: org)) From 04947ab9dfcd0d7140d1c7913952c421f21da4dd Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 15:59:16 +0100 Subject: [PATCH 104/200] Register operator named UAA resource policies when configured --- app/actions/service_account_provision.rb | 7 +++++-- lib/cloud_controller/config_schemas/worker_schema.rb | 3 ++- lib/cloud_controller/dependency_locator.rb | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index c43383c9f27..c37855ed487 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -4,11 +4,12 @@ class Conflict < StandardError; end # identity_ca_ref names an operator-defined UAA trust bundle containing the instance identity CA. # CA rotation changes the bundle's contents in UAA; registered clients never change. - def initialize(clients, identity_ca_ref:) + def initialize(clients, identity_ca_ref:, allowed_resources_ref: nil) raise ArgumentError.new('identity_ca_ref must name a UAA trust bundle') if identity_ca_ref.blank? @clients = clients @identity_ca_ref = identity_ca_ref + @allowed_resources_ref = allowed_resources_ref end def provision(account) @@ -105,7 +106,7 @@ def existing_client(client_id) end def registration(account) - { + profile = { 'client_id' => account.client_id, 'authorized_grant_types' => ['client_credentials'], 'authorities' => %w[cloud_controller.read cloud_controller.write], @@ -123,6 +124,8 @@ def registration(account) 'tls-client-auth-required-claims' => { 'org_guid' => account.organization.guid }, 'cf_service_account_guid' => account.guid } + profile['tls-client-auth-allowed-resources-ref'] = @allowed_resources_ref if @allowed_resources_ref + profile end end end diff --git a/lib/cloud_controller/config_schemas/worker_schema.rb b/lib/cloud_controller/config_schemas/worker_schema.rb index fb96053bd57..e095e79fe74 100644 --- a/lib/cloud_controller/config_schemas/worker_schema.rb +++ b/lib/cloud_controller/config_schemas/worker_schema.rb @@ -32,7 +32,8 @@ class WorkerSchema < VCAP::Config optional(:service_account_provisioning) => { client_id: String, client_secret: String, - identity_ca_ref: String + identity_ca_ref: String, + optional(:allowed_resources_ref) => String }, optional(:service_account_runtime_enabled) => bool, optional(:service_account_runtime_contract_version) => Integer, diff --git a/lib/cloud_controller/dependency_locator.rb b/lib/cloud_controller/dependency_locator.rb index 000553a5bbb..3da652a1937 100644 --- a/lib/cloud_controller/dependency_locator.rb +++ b/lib/cloud_controller/dependency_locator.rb @@ -324,7 +324,7 @@ def service_account_provisioner ca_file: config.get(:uaa, :ca_file) ) register(:service_account_provisioner, - ServiceAccountProvision.new(clients, identity_ca_ref: settings[:identity_ca_ref])) + ServiceAccountProvision.new(clients, identity_ca_ref: settings[:identity_ca_ref], allowed_resources_ref: settings[:allowed_resources_ref])) end def uaa_shadow_user_creation_client From 5ea1178797c72d91ea453ae6e92a7ffe17053ad1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:00:15 +0100 Subject: [PATCH 105/200] Test rejecting new entitlements during account deletion (red) --- spec/request/service_account_entitlements_spec.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index 8b6891955b0..596c9e7d610 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -109,5 +109,11 @@ def entitle(destination, headers) expect(last_response.status).to eq(409) expect(app.reload.service_account_guid).to be_nil end + + it 'refuses adding an entitlement to a deleting account' do + account.update(status: 'deleting', enabled: false) + entitle(target, auth('org_manager')) + expect(last_response.status).to eq(409) + end end end From 513191dba2558056425ec350c93f55c1fe3b42c6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:01:36 +0100 Subject: [PATCH 106/200] Reject entitlement additions once account deletion begins --- app/controllers/v3/service_accounts_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 5f25e363365..5dde8542559 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -149,6 +149,7 @@ def entitle data = entitlement_guids account.db.transaction do account.lock! + lifecycle_conflict!('service account is being deleted') if account.status == 'deleting' require_writable_org!(account.organization) data.each do |guid| space = Space.first(guid: guid) From d80a64bc856bc8063092032e770e2dcd09f72e26 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:02:38 +0100 Subject: [PATCH 107/200] Test account relationships and included sources on route policies (red) --- spec/request/service_account_route_policies_spec.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/request/service_account_route_policies_spec.rb b/spec/request/service_account_route_policies_spec.rb index ca25004888c..75df5c4c221 100644 --- a/spec/request/service_account_route_policies_spec.rb +++ b/spec/request/service_account_route_policies_spec.rb @@ -48,6 +48,9 @@ module VCAP::CloudController expect(parsed_response['source']).to eq(body[:source]) policy = RoutePolicy.first(guid: parsed_response['guid']) expect(policy.source_guid).to eq(account.guid) + get "/v3/route_policies/#{policy.guid}?include=source", nil, headers + expect(parsed_response.dig('relationships', 'service_account', 'data', 'guid')).to eq(account.guid) + expect(parsed_response.dig('included', 'service_accounts').pluck('guid')).to eq([account.guid]) account.destroy expect(RoutePolicy.first(guid: policy.guid)).to be_nil end From b2f04d93664dcd5a22a92b14842b178810fa44fa Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:04:53 +0100 Subject: [PATCH 108/200] Present readable account relationships and included route sources --- .../include_route_policy_source_decorator.rb | 10 ++++++++++ app/presenters/v3/route_policy_presenter.rb | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/decorators/include_route_policy_source_decorator.rb b/app/decorators/include_route_policy_source_decorator.rb index c89d1e72be9..3f7400ff67a 100644 --- a/app/decorators/include_route_policy_source_decorator.rb +++ b/app/decorators/include_route_policy_source_decorator.rb @@ -31,12 +31,22 @@ def decorate(hash, route_policies) hash[:included][:apps] = fetch_and_present_apps(app_guids.uniq, permission_queryer) hash[:included][:spaces] = fetch_and_present_spaces(space_guids.uniq, permission_queryer) hash[:included][:organizations] = fetch_and_present_organizations(org_guids.uniq, permission_queryer) + account_guids = route_policies.select { |policy| policy.source_type == 'svc' }.map(&:source_guid) + hash[:included][:service_accounts] = fetch_and_present_accounts(account_guids, permission_queryer) unless account_guids.empty? hash end private + def fetch_and_present_accounts(guids, permissions) + accounts = ServiceAccountModel.where(guid: guids).all.select do |account| + permissions.can_read_globally? || account.organization.managers_dataset.where(users__id: SecurityContext.current_user.id).any? || + account.entitled_spaces.any? { |space| permissions.can_read_from_space?(space.id, space.organization_id) } + end + accounts.map { |account| Presenters::V3::ServiceAccountPresenter.new(account).to_hash } + end + def fetch_and_present_apps(guids, permission_queryer) return [] if guids.empty? diff --git a/app/presenters/v3/route_policy_presenter.rb b/app/presenters/v3/route_policy_presenter.rb index ddbe02283d8..5e26266d41f 100755 --- a/app/presenters/v3/route_policy_presenter.rb +++ b/app/presenters/v3/route_policy_presenter.rb @@ -36,12 +36,14 @@ def route_policy end def build_relationships - { + relationships = { route: { data: { guid: route_policy.route.guid } }, app: { data: route_policy.source_type == 'app' ? { guid: route_policy.source_guid } : nil }, space: { data: route_policy.source_type == 'space' ? { guid: route_policy.source_guid } : nil }, organization: { data: route_policy.source_type == 'org' ? { guid: route_policy.source_guid } : nil } } + relationships[:service_account] = { data: { guid: route_policy.source_guid } } if route_policy.source_type == 'svc' + relationships end def build_links @@ -61,6 +63,8 @@ def build_links links[:space] = { href: url_builder.build_url(path: "/v3/spaces/#{route_policy.source_guid}") } when 'org' links[:organization] = { href: url_builder.build_url(path: "/v3/organizations/#{route_policy.source_guid}") } + when 'svc' + links[:service_account] = { href: url_builder.build_url(path: "/v3/service_accounts/#{route_policy.source_guid}") } end links From d06c5c851ba99143ce7e33a62ea8cc49ee32d1d4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:07:56 +0100 Subject: [PATCH 109/200] Test safe rollback of an empty account namespace transition (red) --- .../service_account_namespace_rollback_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 spec/migrations/service_account_namespace_rollback_spec.rb diff --git a/spec/migrations/service_account_namespace_rollback_spec.rb b/spec/migrations/service_account_namespace_rollback_spec.rb new file mode 100644 index 00000000000..5a45c26b0c6 --- /dev/null +++ b/spec/migrations/service_account_namespace_rollback_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' + +RSpec.describe 'Service account namespace rollback', isolation: :truncation do + it 'allows reverting an empty namespace transition' do + db = Sequel::Model.db + expect(db[:service_accounts].count).to eq(0) + expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20261109122999) }.not_to raise_error + Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) + end +end From 0914c0e5be0031323f20ae2ce5c59370e4b1bbbf Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:09:54 +0100 Subject: [PATCH 110/200] Allow empty namespace rollback while rejecting lossy identity downgrades --- ...e_service_account_names_to_organizations.rb | 18 +++++++++++++++++- .../service_account_namespace_rollback_spec.rb | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb b/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb index 8a6d6d37ee9..812de42e86d 100644 --- a/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb +++ b/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb @@ -23,6 +23,22 @@ end down do - raise Sequel::Error.new('Org-local identities cannot be downgraded to the global account namespace') + raise Sequel::Error.new('Org-local identities cannot be downgraded to the global account namespace') if from(:service_accounts).any? + + duplicate_names = from(:service_account_names).group(:name).having { count.function.* > 1 }.any? + raise Sequel::Error.new('Org-local reservations cannot be collapsed into a global namespace') if duplicate_names + + alter_table :service_accounts do + drop_index %i[organization_id name], name: :service_accounts_org_name_unique, concurrently: false + drop_column :identity_prefix + add_index :name, unique: true, name: :service_accounts_name_unique, concurrently: false + end + alter_table :service_account_names do + drop_index %i[organization_id name], name: :service_account_names_org_name_unique, concurrently: false + drop_column :organization_id + drop_column :deleted_at + add_index :name, unique: true, name: :service_account_names_name_unique, concurrently: false + end + alter_table(:service_accounts) { add_foreign_key [:name], :service_account_names, key: :name, name: :fk_service_accounts_name } end end diff --git a/spec/migrations/service_account_namespace_rollback_spec.rb b/spec/migrations/service_account_namespace_rollback_spec.rb index 5a45c26b0c6..ac47c51a1bb 100644 --- a/spec/migrations/service_account_namespace_rollback_spec.rb +++ b/spec/migrations/service_account_namespace_rollback_spec.rb @@ -4,7 +4,7 @@ it 'allows reverting an empty namespace transition' do db = Sequel::Model.db expect(db[:service_accounts].count).to eq(0) - expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20261109122999) }.not_to raise_error + expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_109_122_999) }.not_to raise_error Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) end end From 78fae2391afb877bb940318579d536b054303093 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:11:32 +0100 Subject: [PATCH 111/200] Test same-org enforcement on direct entitlement association writes (red) --- spec/unit/models/runtime/service_account_ownership_spec.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/unit/models/runtime/service_account_ownership_spec.rb b/spec/unit/models/runtime/service_account_ownership_spec.rb index 69acbda54c5..7d48a71051b 100644 --- a/spec/unit/models/runtime/service_account_ownership_spec.rb +++ b/spec/unit/models/runtime/service_account_ownership_spec.rb @@ -45,5 +45,10 @@ module VCAP::CloudController expect(Space.first(guid: space.guid)).not_to be_nil expect(Organization.first(id: space.organization_id)).not_to be_nil end + + it 'refuses a cross-org entitlement even when the model association is used directly' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space) + expect { account.add_entitled_space(create(:space)) }.to raise_error(Sequel::ValidationFailed, /organization/) + end end end From 9e0dfcd4d1f19544d09a4833b77a3634535637a6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:13:10 +0100 Subject: [PATCH 112/200] Validate same-organization ownership for every space entitlement --- app/models/runtime/service_account_model.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 8aa954d407a..1751e6d6474 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -8,7 +8,8 @@ class ServiceAccountModel < Sequel::Model(:service_accounts) class: 'VCAP::CloudController::Space', join_table: :service_account_space_entitlements, left_key: :service_account_guid, left_primary_key: :guid, - right_key: :space_guid, right_primary_key: :guid + right_key: :space_guid, right_primary_key: :guid, + before_add: proc { |account, space| account.validate_entitled_space!(space) } 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 @@ -81,6 +82,13 @@ def entitled_to?(space) organization_id == space.organization_id && entitled_spaces_dataset.where(spaces__guid: space.guid).any? end + def validate_entitled_space!(space) + return if organization_id == space.organization_id + + errors.add(:organization, 'must own every entitled space') + raise validation_failed_error + end + def certificate_dns_san identity_prefix ? "#{name}.#{identity_prefix}.svc.identity" : "#{name}.svc.identity" end From 044330d94e13bed65beeac2d7ad82924728ac65d Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:14:38 +0100 Subject: [PATCH 113/200] Test rechecking account management rights under the account lock (red) --- spec/request/service_account_entitlements_spec.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index 596c9e7d610..6a217d3cb25 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -115,5 +115,18 @@ def entitle(destination, headers) entitle(target, auth('org_manager')) expect(last_response.status).to eq(409) end + + it 'rechecks management authority after acquiring the account lock' do + headers = auth('space_developer') + account + allow_any_instance_of(ServiceAccountModel).to receive(:lock!).and_wrap_original do |method, *args| + result = method.call(*args) + method.receiver.remove_all_entitled_spaces + result + end + patch "/v3/service_accounts/#{account.guid}", { description: 'raced update' }.to_json, headers + expect(last_response.status).to eq(403) + expect(account.reload.description).to eq('') + end end end From 0b452e779ebc344a02b27add96a0f95715d847f9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:16:13 +0100 Subject: [PATCH 114/200] Recheck account lifecycle authorization after locking current entitlements --- 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 5dde8542559..ea7cde49d2f 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -83,6 +83,7 @@ def update job = nil account.db.transaction do account.lock! + authorize_account_management!(account) apply_metadata(account, message) if message.requested?(:enabled) require_provisioning! @@ -104,6 +105,7 @@ def destroy job = nil account.db.transaction do account.lock! + authorize_account_management!(account) lifecycle_conflict!('service account is still assigned or in use') if account.in_use? reject_active_operation!(account) account.update(enabled: false, status: 'deleting') @@ -132,7 +134,7 @@ def developer?(space) end def authorize_account_management!(account) - unauthorized! unless org_manager?(account) || account.entitled_spaces.any? { |space| developer?(space) && space.active? } + unauthorized! unless org_manager?(account) || account.entitled_spaces_dataset.all.any? { |space| developer?(space) && space.active? } require_writable_org!(account.organization) end From c700b7dbb57080dd1d80a253a3a0a271e3ece844 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:17:23 +0100 Subject: [PATCH 115/200] Test legacy unqualified identities retain global collision protection (red) --- spec/unit/models/runtime/service_account_model_spec.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/unit/models/runtime/service_account_model_spec.rb b/spec/unit/models/runtime/service_account_model_spec.rb index c1dd41e0503..055044b32a2 100644 --- a/spec/unit/models/runtime/service_account_model_spec.rb +++ b/spec/unit/models/runtime/service_account_model_spec.rb @@ -59,5 +59,10 @@ def reserve(name, owner=space) expect(model.where(guid: account.guid).first).to be_nil expect { reserve('payments-worker', create(:space, organization: space.organization)) }.to raise_error(Sequel::ValidationFailed, /name/) end + + it 'keeps unqualified legacy identities foundation-unique across organizations' do + reserve('legacy-worker') + expect { reserve('legacy-worker', create(:space)) }.to raise_error(Sequel::ValidationFailed, /reserved/) + end end end From 37d9ad87ebcf393531ddfaf3a0005710e6b820dd Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:20:05 +0100 Subject: [PATCH 116/200] Preserve global collision reservations for retiring unqualified identities --- app/models/runtime/service_account_model.rb | 6 ++++++ ...09130000_preserve_legacy_account_namespace.rb | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 db/migrations/20261109130000_preserve_legacy_account_namespace.rb diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 1751e6d6474..e3346226301 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -40,6 +40,7 @@ def validate_identity_immutability def around_create db.transaction(savepoint: true) do organization.lock! + reserve_legacy_namespace! unless identity_prefix quota = organization.quota_definition limit = quota.total_service_accounts if limit != -1 && self.class.where(organization_id: organization_id).count >= limit @@ -56,6 +57,11 @@ def around_create raise validation_failed_error end + def reserve_legacy_namespace! + # The pre-prefix namespace remains globally reserved throughout retirement. + db[:legacy_service_account_names].insert(name: name) # rubocop:disable Rails/SkipsModelValidations -- Atomic Sequel namespace reservation. + end + def after_create super add_entitled_space(space) diff --git a/db/migrations/20261109130000_preserve_legacy_account_namespace.rb b/db/migrations/20261109130000_preserve_legacy_account_namespace.rb new file mode 100644 index 00000000000..ec4c06aac6d --- /dev/null +++ b/db/migrations/20261109130000_preserve_legacy_account_namespace.rb @@ -0,0 +1,16 @@ +Sequel.migration do + up do + create_table :legacy_service_account_names do + primary_key :id, name: :legacy_service_account_names_pkey + String :name, size: 63, null: false + index :name, unique: true, name: :legacy_service_account_names_unique + end + from(:service_account_names).select(:name).distinct.each do |reservation| + from(:legacy_service_account_names).insert(name: reservation[:name]) + end + end + + down do + drop_table :legacy_service_account_names + end +end From 60fe0f5f451e80bb8de929dee093943f6c0d9485 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:23:38 +0100 Subject: [PATCH 117/200] Test preserving pre-identity broker binding semantics (red) --- .../actions/service_account_binding_guard_spec.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spec/unit/actions/service_account_binding_guard_spec.rb b/spec/unit/actions/service_account_binding_guard_spec.rb index 79771379b3e..d585d13d029 100644 --- a/spec/unit/actions/service_account_binding_guard_spec.rb +++ b/spec/unit/actions/service_account_binding_guard_spec.rb @@ -3,6 +3,17 @@ module VCAP::CloudController RSpec.describe 'Service account identity binding guard' do + it 'allows removing an assignment when the binding predates account assignment' do + space = create(:space, organization: create(:organization, service_account_prefix: 'binding-team')) + app = create(:app_model, space: space) + create(:service_binding, app: app, service_instance: create(:managed_service_instance, space: space)) + account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') + permissions = double(can_write_to_active_space?: true) + action = AppAssignServiceAccount.new(permissions) + action.assign(app, account) + expect { action.assign(app, nil) }.not_to raise_error + end + it 'refuses to unassign an account while a broker binding depends on the identity' do space = create(:space) account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') From 496080ac920c4fc75f8d3f5f0fdae5689339d082 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:25:34 +0100 Subject: [PATCH 118/200] Snapshot account identity on service bindings and retain legacy binding semantics --- app/actions/app_assign_service_account.rb | 2 +- app/models/services/service_binding.rb | 1 + .../20261109131000_snapshot_service_binding_account.rb | 5 +++++ lib/services/service_brokers/v2/client.rb | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 db/migrations/20261109131000_snapshot_service_binding_account.rb diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 300f4642282..3a6db84b4a5 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -42,7 +42,7 @@ def assign(app, account, provision: false) private def validate_binding_change!(app, account) - return unless app.service_account_guid && app.service_account_guid != account&.guid && app.service_bindings_dataset.any? + return unless app.service_account_guid && app.service_account_guid != account&.guid && app.service_bindings_dataset.exclude(service_account_guid: nil).any? raise Conflict.new('remove service bindings before changing the service account') end diff --git a/app/models/services/service_binding.rb b/app/models/services/service_binding.rb index bd38acb9ed0..c6a448d8793 100644 --- a/app/models/services/service_binding.rb +++ b/app/models/services/service_binding.rb @@ -98,6 +98,7 @@ def before_update end def before_create + self.service_account_guid = app.service_account_guid if app encode_syslog_drain_url_commas super end diff --git a/db/migrations/20261109131000_snapshot_service_binding_account.rb b/db/migrations/20261109131000_snapshot_service_binding_account.rb new file mode 100644 index 00000000000..7098fe61ac1 --- /dev/null +++ b/db/migrations/20261109131000_snapshot_service_binding_account.rb @@ -0,0 +1,5 @@ +Sequel.migration do + change do + alter_table(:service_bindings) { add_column :service_account_guid, String, size: 255 } + end +end diff --git a/lib/services/service_brokers/v2/client.rb b/lib/services/service_brokers/v2/client.rb index cb268803175..0f49d8ef736 100644 --- a/lib/services/service_brokers/v2/client.rb +++ b/lib/services/service_brokers/v2/client.rb @@ -370,7 +370,9 @@ def context_hash(service_instance) end def add_service_account_context(body, binding) - account = binding.respond_to?(:app) ? binding.app&.service_account : nil + return unless binding.respond_to?(:service_account_guid) && binding.service_account_guid + + account = VCAP::CloudController::ServiceAccountModel.first(guid: binding.service_account_guid) return unless account unless account.enabled && account.status == 'ready' && account.entitled_to?(binding.app.space) From c6d854db67d21eba0a2fdf4efd5a89b6bccffd98 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:26:41 +0100 Subject: [PATCH 119/200] Test identity-bearing bindings protect account lifecycle references (red) --- .../unit/actions/service_account_binding_guard_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/unit/actions/service_account_binding_guard_spec.rb b/spec/unit/actions/service_account_binding_guard_spec.rb index d585d13d029..bcfaa5e5a66 100644 --- a/spec/unit/actions/service_account_binding_guard_spec.rb +++ b/spec/unit/actions/service_account_binding_guard_spec.rb @@ -3,6 +3,16 @@ module VCAP::CloudController RSpec.describe 'Service account identity binding guard' do + it 'counts identity-bearing bindings as account lifecycle references' do + space = create(:space) + account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') + app = create(:app_model, space: space, service_account: account) + create(:service_binding, app: app, service_instance: create(:managed_service_instance, space: space)) + app.update(service_account: nil) + expect(account.in_use?).to be(true) + expect(account.in_use_in_space?(space)).to be(true) + end + it 'allows removing an assignment when the binding predates account assignment' do space = create(:space, organization: create(:organization, service_account_prefix: 'binding-team')) app = create(:app_model, space: space) From 38372320e25488862222973b6b425a45f418e2fb Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:28:16 +0100 Subject: [PATCH 120/200] Protect account deletion and entitlement revocation with binding references --- app/models/runtime/service_account_model.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index e3346226301..a8d7624126c 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -72,14 +72,14 @@ def client_id end def in_use? - apps_dataset.any? || + apps_dataset.any? || ServiceBinding.where(service_account_guid: guid).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 in_use_in_space?(space) app_guids = AppModel.where(space_guid: space.guid).select(:guid) - apps_dataset.where(space_guid: space.guid).any? || + apps_dataset.where(space_guid: space.guid).any? || ServiceBinding.where(app_guid: app_guids, service_account_guid: guid).any? || ProcessModel.where(app_guid: app_guids, service_account_guid: guid, state: ProcessModel::STARTED).any? || TaskModel.where(app_guid: app_guids, service_account_guid: guid, state: [TaskModel::PENDING_STATE, TaskModel::RUNNING_STATE, TaskModel::CANCELING_STATE]).any? end From e049719bae3b41ec5b4b217ab27050f712b22437 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:29:33 +0100 Subject: [PATCH 121/200] Test binding snapshots revalidate account entitlement at persistence (red) --- .../unit/actions/service_account_binding_guard_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/unit/actions/service_account_binding_guard_spec.rb b/spec/unit/actions/service_account_binding_guard_spec.rb index bcfaa5e5a66..4fc4f4bc3fd 100644 --- a/spec/unit/actions/service_account_binding_guard_spec.rb +++ b/spec/unit/actions/service_account_binding_guard_spec.rb @@ -3,6 +3,16 @@ module VCAP::CloudController RSpec.describe 'Service account identity binding guard' do + it 'rejects binding snapshots whose account entitlement was revoked before persistence' do + space = create(:space) + account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') + app = create(:app_model, space: space, service_account: account) + account.remove_all_entitled_spaces + expect do + create(:service_binding, app: app, service_instance: create(:managed_service_instance, space: space)) + end.to raise_error(Sequel::ValidationFailed, /entitled/) + end + it 'counts identity-bearing bindings as account lifecycle references' do space = create(:space) account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') From aea3efbf4f9a7cb9b0b40a780b51c2309301dd5d Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:31:14 +0100 Subject: [PATCH 122/200] Lock app and account before capturing broker binding identity --- app/models/services/service_binding.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/models/services/service_binding.rb b/app/models/services/service_binding.rb index c6a448d8793..8bd98d697be 100644 --- a/app/models/services/service_binding.rb +++ b/app/models/services/service_binding.rb @@ -98,7 +98,16 @@ def before_update end def before_create - self.service_account_guid = app.service_account_guid if app + if app + app.lock! + account = app.service_account + account&.lock! + if account && !account.entitled_to?(app.space) + errors.add(:service_account, 'must be entitled to the app space') + raise validation_failed_error + end + self.service_account_guid = account&.guid + end encode_syslog_drain_url_commas super end From 159287d70058aa5f6cd29175f66916fa9ccf354c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:32:21 +0100 Subject: [PATCH 123/200] Test launch snapshot rechecks the account space entitlement (red) --- .../unit/models/runtime/service_account_ownership_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/unit/models/runtime/service_account_ownership_spec.rb b/spec/unit/models/runtime/service_account_ownership_spec.rb index 7d48a71051b..7fee19db422 100644 --- a/spec/unit/models/runtime/service_account_ownership_spec.rb +++ b/spec/unit/models/runtime/service_account_ownership_spec.rb @@ -50,5 +50,13 @@ module VCAP::CloudController account = ServiceAccountModel.create(name: 'payments-worker', space: space) expect { account.add_entitled_space(create(:space)) }.to raise_error(Sequel::ValidationFailed, /organization/) end + + it 'refuses starting a process after its desired account entitlement disappears' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space, status: 'ready') + app = create(:app_model, space: space, service_account: account) + process = create(:process_model, app: app, state: ProcessModel::STOPPED) + account.remove_all_entitled_spaces + expect { process.update(state: ProcessModel::STARTED) }.to raise_error(Sequel::ValidationFailed, /entitled/) + end end end From 49165a4417b1bb599b5c933c4497269194a351a9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:34:24 +0100 Subject: [PATCH 124/200] Lock and revalidate account entitlement before process launch snapshots --- app/models/runtime/process_model.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/models/runtime/process_model.rb b/app/models/runtime/process_model.rb index d376ba07695..be92ac3e725 100644 --- a/app/models/runtime/process_model.rb +++ b/app/models/runtime/process_model.rb @@ -305,6 +305,12 @@ def before_validation def before_save if being_started? + account = app.service_account + account&.lock! + if account && !account.entitled_to?(app.space) + errors.add(:service_account, 'must be entitled to the app space') + raise validation_failed_error + end self.service_account_guid = app.service_account_guid self.service_account_snapshot = true end From b724da38f3d979116d2fc08d52170cbf456e67a1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:36:26 +0100 Subject: [PATCH 125/200] Test task launch serializes account entitlement validation (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 e6c46ac8265..ef6aadf7798 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 'refuses a task when the account entitlement disappears before launch' do + account = ServiceAccountModel.create(name: 'task-worker', space: space, status: 'ready') + app.update(service_account: account) + account.remove_all_entitled_spaces + expect_any_instance_of(ServiceAccountModel).to receive(:lock!).and_call_original + expect { task_create_action.create(app, message, user_audit_info) }.to raise_error(TaskCreate::InvalidTask, /entitled/) + 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) From a35823030adba2b1d745a1769267ba57277cac91 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:38:04 +0100 Subject: [PATCH 126/200] Lock account entitlement when creating runtime task snapshots --- app/actions/task_create.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/actions/task_create.rb b/app/actions/task_create.rb index 36a7fc49cac..d2662cdb367 100644 --- a/app/actions/task_create.rb +++ b/app/actions/task_create.rb @@ -19,6 +19,9 @@ def create(app, message, user_audit_info, droplet: nil) task = nil TaskModel.db.transaction do app.lock! + account = app.service_account + account&.lock! + raise InvalidTask.new('service account must be entitled to the app space') if account && !account.entitled_to?(app.space) template_process = process_from_template(message) task = TaskModel.create( From 35a434eb605008600840004d9319e5b893d42128 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:39:37 +0100 Subject: [PATCH 127/200] Test broker binding fails closed for a missing captured account (red) --- spec/unit/lib/services/service_brokers/v2/client_spec.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec/unit/lib/services/service_brokers/v2/client_spec.rb b/spec/unit/lib/services/service_brokers/v2/client_spec.rb index a7db191b42b..1e2f2b81897 100644 --- a/spec/unit/lib/services/service_brokers/v2/client_spec.rb +++ b/spec/unit/lib/services/service_brokers/v2/client_spec.rb @@ -1449,6 +1449,12 @@ module VCAP::Services::ServiceBrokers::V2 expect(http_client).to have_received(:put).with(anything, hash_including(context: context), anything) end + it 'fails closed rather than sending a credential-only bind when its captured account is missing' do + binding.update(service_account_guid: SecureRandom.uuid) + expect { client.bind(binding) }.to raise_error(VCAP::CloudController::ServiceAccountProvision::Conflict, /not found/) + expect(http_client).not_to have_received(:put) + end + it 'makes a put request with correct path' do client.bind(binding) From f1c001de39e6b2230edc2c35e94dbdff9a6b8033 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:41:18 +0100 Subject: [PATCH 128/200] Refuse broker requests when a captured account identity is missing --- lib/services/service_brokers/v2/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/service_brokers/v2/client.rb b/lib/services/service_brokers/v2/client.rb index 0f49d8ef736..9184b75dfd2 100644 --- a/lib/services/service_brokers/v2/client.rb +++ b/lib/services/service_brokers/v2/client.rb @@ -373,7 +373,7 @@ def add_service_account_context(body, binding) return unless binding.respond_to?(:service_account_guid) && binding.service_account_guid account = VCAP::CloudController::ServiceAccountModel.first(guid: binding.service_account_guid) - return unless account + raise VCAP::CloudController::ServiceAccountProvision::Conflict.new('captured service account not found') unless account unless account.enabled && account.status == 'ready' && account.entitled_to?(binding.app.space) raise VCAP::CloudController::ServiceAccountProvision::Conflict.new('service account is not ready for a broker binding') From 60a8b9acd41b331246f2a397c616be17754cd3ea Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:42:31 +0100 Subject: [PATCH 129/200] Test rejecting entitlements to deleting spaces (red) --- spec/request/service_account_entitlements_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index 6a217d3cb25..1dd54b9b6dc 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -128,5 +128,20 @@ def entitle(destination, headers) expect(last_response.status).to eq(403) expect(account.reload.description).to eq('') end + + it 'does not let a developer bootstrap authority by ordering a multi-space entitlement request' do + headers = auth('space_developer') + target.add_developer(user) + account.remove_all_entitled_spaces + post path, { data: [{ guid: target.guid }, { guid: space.guid }] }.to_json, headers + expect(last_response.status).to eq(404) + end + + it 'does not entitle a space whose deletion has begun' do + target.update(status: Space::DELETING) + entitle(target, auth('org_manager')) + expect(last_response.status).to eq(422) + expect(account.reload.entitled_spaces).to contain_exactly(space) + end end end From 7f056fbb7eaff74012ee03011260b335e438d148 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:44:12 +0100 Subject: [PATCH 130/200] Reject granting account entitlements to deleting spaces --- app/controllers/v3/service_accounts_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index ea7cde49d2f..dbb76ec2207 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -156,6 +156,7 @@ def entitle data.each do |guid| space = Space.first(guid: guid) unprocessable!('Space must belong to the account organization') unless space && space.organization_id == account.organization_id + unprocessable!('Space is being deleted') if space.deleting? unauthorized! unless org_manager?(account) || (developer?(space) && account.entitled_spaces.any? { |source| developer?(source) }) require_writable_space!(space) account.add_entitled_space(space) unless account.entitled_spaces_dataset.where(spaces__guid: space.guid).any? From 3c7b0dee88faf5d262abed3d75175b7e4d020eb4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:45:27 +0100 Subject: [PATCH 131/200] Test failed org account cleanup leaves a deleting parent (red) --- spec/unit/actions/service_account_org_lifecycle_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/unit/actions/service_account_org_lifecycle_spec.rb b/spec/unit/actions/service_account_org_lifecycle_spec.rb index 23b7b84b1fc..046e39318c4 100644 --- a/spec/unit/actions/service_account_org_lifecycle_spec.rb +++ b/spec/unit/actions/service_account_org_lifecycle_spec.rb @@ -46,6 +46,7 @@ module VCAP::CloudController expect(org_delete.delete([org]).map(&:message).join).to include('UAA unavailable') expect(Organization.first(id: org.id)).not_to be_nil + expect(org.reload.status).to eq(Organization::DELETING) expect(account.reload.status).to eq('failed') allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) From 854960007baeabb6fca82f06c5221c32f5f036af Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:49:05 +0100 Subject: [PATCH 132/200] Mark orgs deleting before retryable service account cleanup --- app/actions/organization_delete.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/actions/organization_delete.rb b/app/actions/organization_delete.rb index 0ad822dfbdf..3afbbb7d9a5 100644 --- a/app/actions/organization_delete.rb +++ b/app/actions/organization_delete.rb @@ -11,6 +11,7 @@ def initialize(space_deleter, user_audit_info) def delete(org_dataset) org_dataset.each do |org| + mark_deleting_for_account_cleanup(org) errs = @space_deleter.delete(org.spaces_dataset) unless errs.empty? error_message = errs.map(&:message).join("\n\n") @@ -49,6 +50,15 @@ def timeout_error(dataset) private + def mark_deleting_for_account_cleanup(org) + return unless ServiceAccountModel.where(organization_id: org.id).any? + + org.db.transaction do + org.lock! + org.update(status: Organization::DELETING) + end + end + def delete_service_accounts(org) accounts = ServiceAccountModel.where(organization_id: org.id).order(:guid).all return [] if accounts.empty? From 0fe6772e1e33492bd858882daa4dd3d28b98ef5b Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:50:32 +0100 Subject: [PATCH 133/200] Test reservation deletion counts and age observability (red) --- .../unit/models/runtime/service_account_ownership_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/unit/models/runtime/service_account_ownership_spec.rb b/spec/unit/models/runtime/service_account_ownership_spec.rb index 7fee19db422..7fe3c56a8aa 100644 --- a/spec/unit/models/runtime/service_account_ownership_spec.rb +++ b/spec/unit/models/runtime/service_account_ownership_spec.rb @@ -58,5 +58,13 @@ module VCAP::CloudController account.remove_all_entitled_spaces expect { process.update(state: ProcessModel::STARTED) }.to raise_error(Sequel::ValidationFailed, /entitled/) end + + it 'measures known deletion counts and age buckets without assigning dates to historical reservations' do + account = ServiceAccountModel.create(name: 'payments-worker', space: space) + account.destroy + stats = ServiceAccountModel.reservation_statistics + expect(stats).to include(reserved_names: 1, deletions: 1, unknown_deletion_times: 0) + expect(stats[:deletion_age_days]).to include('0-7' => 1) + end end end From 7f93c669102004c468ec419779aa18e42ac7a691 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:52:20 +0100 Subject: [PATCH 134/200] Expose account reservation counts and deletion age statistics --- app/models/runtime/service_account_model.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index a8d7624126c..d6c31babab4 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -2,6 +2,21 @@ module VCAP::CloudController class ServiceAccountModel < Sequel::Model(:service_accounts) NAME_PATTERN = /\A[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\z/ + def self.reservation_statistics(now: Time.now.utc) + reservations = db[:service_account_names] + history = db[:service_account_name_deletions] + ages = { '0-7' => 0, '8-30' => 0, '31-90' => 0, '91+' => 0 } + [7, 30, 90].each_with_index do |days, index| + younger_than = now - (days * 86_400) + older_than = index.zero? ? now : now - ([7, 30][index - 1] * 86_400) + ages[ages.keys[index]] = history.where { deleted_at >= younger_than }.where { deleted_at <= older_than }.count + end + ages['91+'] = history.where { deleted_at < now - (90 * 86_400) }.count + { reserved_names: reservations.count, deletions: history.count, + unknown_deletion_times: reservations.where(deleted_at: nil).exclude(name: dataset.select(:name)).count, + deletion_age_days: ages } + end + many_to_one :space, key: :space_guid, primary_key: :guid, without_guid_generation: true many_to_one :organization many_to_many :entitled_spaces, From 6dd9039846f4c9d311645954993cb6551d659bf0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:53:23 +0100 Subject: [PATCH 135/200] Test reservation observability uses the organization namespace (red) --- spec/unit/models/runtime/service_account_ownership_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/unit/models/runtime/service_account_ownership_spec.rb b/spec/unit/models/runtime/service_account_ownership_spec.rb index 7fe3c56a8aa..a1ea7722d47 100644 --- a/spec/unit/models/runtime/service_account_ownership_spec.rb +++ b/spec/unit/models/runtime/service_account_ownership_spec.rb @@ -66,5 +66,12 @@ module VCAP::CloudController expect(stats).to include(reserved_names: 1, deletions: 1, unknown_deletion_times: 0) expect(stats[:deletion_age_days]).to include('0-7' => 1) end + + it 'counts an orphan reservation as unknown even when another org uses the same local name' do + other_org = create(:organization, service_account_prefix: 'stats-team') + ServiceAccountModel.create(name: 'shared-name', space: create(:space, organization: other_org)) + ServiceAccountModel.db[:service_account_names].insert(name: 'shared-name', organization_id: space.organization_id) + expect(ServiceAccountModel.reservation_statistics[:unknown_deletion_times]).to eq(1) + end end end From 9a1c6887a62a99412ceac6555bd447e7f06f3b82 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:54:55 +0100 Subject: [PATCH 136/200] Scope unknown reservation age statistics to organization and name --- app/models/runtime/service_account_model.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index d6c31babab4..f5688364ab7 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -13,7 +13,10 @@ def self.reservation_statistics(now: Time.now.utc) end ages['91+'] = history.where { deleted_at < now - (90 * 86_400) }.count { reserved_names: reservations.count, deletions: history.count, - unknown_deletion_times: reservations.where(deleted_at: nil).exclude(name: dataset.select(:name)).count, + unknown_deletion_times: reservations.where(deleted_at: nil).where(Sequel.~(dataset.where( + Sequel[:service_accounts][:name] => Sequel[:service_account_names][:name], + Sequel[:service_accounts][:organization_id] => Sequel[:service_account_names][:organization_id] + ).exists)).count, deletion_age_days: ages } end From 6e75d80a103bc0536695b7fd917ee465fcd6bb8f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 16:59:24 +0100 Subject: [PATCH 137/200] Test rejecting route grants while account deletion is active (red) --- spec/request/service_account_route_policies_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/request/service_account_route_policies_spec.rb b/spec/request/service_account_route_policies_spec.rb index 75df5c4c221..3f1ab26b736 100644 --- a/spec/request/service_account_route_policies_spec.rb +++ b/spec/request/service_account_route_policies_spec.rb @@ -2,6 +2,18 @@ module VCAP::CloudController RSpec.describe 'Service account route sources' do + it 'refuses a grant to an account whose deletion has begun' do + TestConfig.override(service_account_router_contract_version: 2) + org = create(:organization, service_account_prefix: 'route-team') + space = create(:space, organization: org) + ServiceAccountModel.create(name: 'route-worker', space: space, status: 'deleting', enabled: false) + domain = create(:private_domain, owning_organization: org, enforce_route_policies: true) + route = create(:route, space: space, domain: domain) + body = { source: 'cf:svc:route-team:route-worker', relationships: { route: { data: { guid: route.guid } } } } + post '/v3/route_policies', body.to_json, admin_headers_for(create(:user)) + expect(last_response.status).to eq(422) + end + it 'refuses account rules until qualified routers are explicitly enabled' do org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) From 6a11a0e9c0b53dcebd3adf169d299f4ad8660d78 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:01:03 +0100 Subject: [PATCH 138/200] Serialize account route grants against account deletion --- app/actions/route_policy_create.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/actions/route_policy_create.rb b/app/actions/route_policy_create.rb index b2905c15b46..567f9e1ba81 100644 --- a/app/actions/route_policy_create.rb +++ b/app/actions/route_policy_create.rb @@ -13,6 +13,7 @@ def create(route:, message:) validate_scope!(route.domain, message.source) route_policy = RoutePolicy.db.transaction do + lock_account_source!(message.source) # Lock the parent route row to serialize concurrent creates. # SELECT ... FOR UPDATE on an empty policies table acquires no row locks, # so two concurrent transactions can both read [] and both pass cf:any @@ -45,6 +46,17 @@ def create(route:, message:) private + def lock_account_source!(source) + match = source.match(RoutePolicy::SERVICE_ACCOUNT_SOURCE_REGEX) + return unless match + + account = ServiceAccountModel.first(identity_prefix: match[1], name: match[2]) + raise Error.new('Service account not found') unless account + + account.lock! + raise Error.new('Service account is being deleted') if account.status == 'deleting' + end + def validate_scope!(domain, source) return unless domain.route_policies_scope == 'space' return unless source.start_with?('cf:org:') From bc73646f92b38dba712f033d0699e9edd936c1a7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:04:15 +0100 Subject: [PATCH 139/200] Test revocation response after the caller loses account visibility (red) --- .../service_account_entitlements_spec.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index 1dd54b9b6dc..ebb7bdd14f0 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -143,5 +143,27 @@ def entitle(destination, headers) expect(last_response.status).to eq(422) expect(account.reload.entitled_spaces).to contain_exactly(space) end + + it 'authorizes entitlement expansion using current source access under the account lock' do + headers = auth('space_developer') + target.add_developer(user) + account + allow_any_instance_of(ServiceAccountModel).to receive(:lock!).and_wrap_original do |method, *args| + result = method.call(*args) + method.receiver.remove_all_entitled_spaces + result + end + entitle(target, headers) + expect(last_response.status).to eq(403) + end + + it 'returns success when revoking the callers last readable entitlement while another space remains' do + headers = auth('admin') + entitle(target, headers) + expect(last_response.status).to eq(200) + delete "#{path}/#{space.guid}", nil, auth('space_developer') + expect(last_response.status).to eq(200) + expect(account.reload.entitled_spaces).to contain_exactly(target) + end end end From c1bc21acc44fe3e43eebdc9479e61b32de7a9d81 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:05:53 +0100 Subject: [PATCH 140/200] Return successful entitlement revocation without reauthorizing the removed space --- app/controllers/v3/service_accounts_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index dbb76ec2207..816f7f139cc 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -179,7 +179,7 @@ def unentitle account.remove_entitled_space(space) Repositories::ServiceAccountEventRepository.record(account, 'unentitle', user_audit_info, space_guid: space.guid) end - spaces + render status: :ok, json: { data: account.entitled_spaces_dataset.all.map { |space| { guid: space.guid } } } end private From a5ed905958fdcd0fc440ef8d17cbbc0a49de0e43 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:07:28 +0100 Subject: [PATCH 141/200] Test account name reuse warnings and prior identity audit linkage (red) --- .../service_account_namespaces_spec.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/request/service_account_namespaces_spec.rb b/spec/request/service_account_namespaces_spec.rb index a179ef8e57a..2ecc263ce11 100644 --- a/spec/request/service_account_namespaces_spec.rb +++ b/spec/request/service_account_namespaces_spec.rb @@ -55,5 +55,24 @@ def auth(role='space_developer') expect(last_response.status).to eq(201) expect(ServiceAccountModel.db[:service_account_name_deletions].first(account_guid: original.guid)).to include(deleted_at: timestamp) end + + it 'refuses a previously fetched actor after its last entitlement is revoked' do + headers = auth + post '/v3/service_accounts', body.to_json, headers + account = ServiceAccountModel.first(guid: parsed_response['guid']) + account.remove_all_entitled_spaces + patch "/v3/service_accounts/#{account.guid}", { description: 'forbidden' }.to_json, headers + expect(last_response.status).to eq(404) + end + + it 'keeps reuse warnings and the previous account GUID in durable audit evidence' do + post '/v3/service_accounts', body.to_json, auth + old_guid = parsed_response['guid'] + ServiceAccountModel.first(guid: old_guid).destroy + post '/v3/service_accounts', body.merge(reuse_name: true).to_json, auth('org_manager') + expect(last_response.status).to eq(201) + expect(last_response.headers['X-Cf-Warnings']).to match(/external|certificate/i) + expect(Event.first(type: 'audit.service_account.create', actee: parsed_response['guid']).metadata['previous_account_guid']).to eq(old_guid) + end end end From 1b5bbca5ebaa4046a72321a1e8ca3f6e09569392 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:09:46 +0100 Subject: [PATCH 142/200] Warn on account name reuse and link the prior identity in audit history --- app/controllers/v3/service_accounts_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 816f7f139cc..e899119d50e 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -54,6 +54,7 @@ def create unprocessable!('Organization service account prefix is unset') unless space.organization.service_account_prefix account = nil + previous_guid = nil ServiceAccountModel.db.transaction do space.organization.lock! limit = space.organization.quota_definition.total_service_accounts @@ -61,13 +62,16 @@ def create ServiceAccountCreationBudget.consume(current_user, exempt: permission_queryer.can_write_globally?) do if reuse lifecycle_conflict!('live account name is already in use') if ServiceAccountModel.where(organization_id: space.organization_id, name: message.name).any? + previous_guid = ServiceAccountModel.db[:service_account_name_deletions]. + where(organization_guid: space.organization.guid, name: message.name).order(Sequel.desc(:id)).get(:account_guid) ServiceAccountModel.db[:service_account_names].where(organization_id: space.organization_id, name: message.name).delete end account = ServiceAccountModel.create(name: message.name, space: space) apply_metadata(account, message) - Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info, reuse_name: reuse) + Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info, reuse_name: reuse, previous_account_guid: previous_guid) end end + add_warning_headers(['Reusing this identity can inherit external grants and accept still-valid old certificates.']) if reuse 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') From eb18c0bd0b64c012cb151edbbd2a9c5191f16ed8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:11:45 +0100 Subject: [PATCH 143/200] Read prior account deletion from the timestamped history row --- app/controllers/v3/service_accounts_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index e899119d50e..8c80d1388d1 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -63,7 +63,7 @@ def create if reuse lifecycle_conflict!('live account name is already in use') if ServiceAccountModel.where(organization_id: space.organization_id, name: message.name).any? previous_guid = ServiceAccountModel.db[:service_account_name_deletions]. - where(organization_guid: space.organization.guid, name: message.name).order(Sequel.desc(:id)).get(:account_guid) + where(organization_guid: space.organization.guid, name: message.name).order(Sequel.desc(:deleted_at)).first&.fetch(:account_guid) ServiceAccountModel.db[:service_account_names].where(organization_id: space.organization_id, name: message.name).delete end account = ServiceAccountModel.create(name: message.name, space: space) From f6c9262ffb3116f50bda88ec55ba8b2e3d0ca882 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:13:11 +0100 Subject: [PATCH 144/200] Test account creation rechecks deleting org after locking (red) --- spec/request/service_account_namespaces_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/request/service_account_namespaces_spec.rb b/spec/request/service_account_namespaces_spec.rb index 2ecc263ce11..452154fdaf4 100644 --- a/spec/request/service_account_namespaces_spec.rb +++ b/spec/request/service_account_namespaces_spec.rb @@ -74,5 +74,17 @@ def auth(role='space_developer') expect(last_response.headers['X-Cf-Warnings']).to match(/external|certificate/i) expect(Event.first(type: 'audit.service_account.create', actee: parsed_response['guid']).metadata['previous_account_guid']).to eq(old_guid) end + + it 'refuses creation if the org starts deleting before its lock is acquired' do + headers = auth + allow_any_instance_of(Organization).to receive(:lock!).and_wrap_original do |method, *args| + result = method.call(*args) + method.receiver.update(status: Organization::DELETING) + result + end + post '/v3/service_accounts', body.to_json, headers + expect(last_response.status).to eq(422) + expect(ServiceAccountModel.count).to eq(0) + end end end From 60f70014897627c7af107328547dd6ccbe17d748 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:15:01 +0100 Subject: [PATCH 145/200] Reject new accounts when org deletion wins the organization lock --- app/controllers/v3/service_accounts_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 8c80d1388d1..de527ac44f3 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -57,6 +57,7 @@ def create previous_guid = nil ServiceAccountModel.db.transaction do space.organization.lock! + unprocessable!('Organization is being deleted') if space.organization.deleting? limit = space.organization.quota_definition.total_service_accounts unprocessable!('Organization service account quota exceeded') if limit != -1 && ServiceAccountModel.where(organization_id: space.organization_id).count >= limit ServiceAccountCreationBudget.consume(current_user, exempt: permission_queryer.can_write_globally?) do From b5c0dfa89640884bebf34bfb8f48c062ec4d09e1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:23:09 +0100 Subject: [PATCH 146/200] Test role management for qualified account principals (red) --- spec/request/service_account_roles_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/request/service_account_roles_spec.rb b/spec/request/service_account_roles_spec.rb index ea8e1ed085c..e8a6f1a8ccb 100644 --- a/spec/request/service_account_roles_spec.rb +++ b/spec/request/service_account_roles_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' RSpec.describe 'Explicit service account resource roles' do - let(:space) { create(:space) } + let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'roles-team')) } let(:admin) { create(:user) } let(:account) { VCAP::CloudController::ServiceAccountModel.create(name: 'payments-worker', space: space) } let(:clients) { instance_double(CF::UAA::Scim) } @@ -39,7 +39,7 @@ def grant(type, resource, guid) 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) + expect(last_response.status).to eq(201) end it 'keeps account tokens denied in unrelated spaces after an explicit role grant' do From 73d6b8819dc8a7714a4b7ff3e09f2dcfb7449812 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:25:03 +0100 Subject: [PATCH 147/200] Resolve qualified account principals without human UAA username lookup --- app/collection_transformers/username_populator.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/collection_transformers/username_populator.rb b/app/collection_transformers/username_populator.rb index 99892d72a54..32d093f7ab7 100644 --- a/app/collection_transformers/username_populator.rb +++ b/app/collection_transformers/username_populator.rb @@ -8,7 +8,8 @@ def initialize(uaa_client) def transform(users, _opts={}) users = Array(users) - accounts = ServiceAccountModel.where(name: users.select(&:is_oauth_client).map { |user| user.guid.delete_prefix('cf:service-account:') }).all + names = users.select(&:is_oauth_client).map { |user| user.guid.delete_prefix('cf:service-account:').split(':').last } + accounts = ServiceAccountModel.where(name: names).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)) From de04b442bace43bbff9def4f2623945bdf28cf35 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:26:42 +0100 Subject: [PATCH 148/200] Test shared-domain safeguards remain during org account cleanup (red) --- spec/unit/actions/service_account_org_lifecycle_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/unit/actions/service_account_org_lifecycle_spec.rb b/spec/unit/actions/service_account_org_lifecycle_spec.rb index 046e39318c4..2bdda06d7bb 100644 --- a/spec/unit/actions/service_account_org_lifecycle_spec.rb +++ b/spec/unit/actions/service_account_org_lifecycle_spec.rb @@ -54,5 +54,14 @@ module VCAP::CloudController expect(ServiceAccountModel.first(guid: account.guid)).to be_nil expect(Organization.first(id: org.id)).to be_nil end + + it 'does not bypass shared-domain deletion guards when accounts mark an org deleting' do + account + domain = create(:private_domain, owning_organization: org) + domain.add_shared_organization(create(:organization)) + expect(org_delete.delete([org]).map(&:message).join).to include('shared') + expect(Organization.first(id: org.id)).not_to be_nil + expect(PrivateDomain.first(guid: domain.guid)).not_to be_nil + end end end From 1213f2f18a7c257ece5f67ad7463084d9efb1700 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:29:34 +0100 Subject: [PATCH 149/200] Check shared domains before marking account-owning organizations deleting --- app/actions/organization_delete.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/actions/organization_delete.rb b/app/actions/organization_delete.rb index 3afbbb7d9a5..0d1214a5d43 100644 --- a/app/actions/organization_delete.rb +++ b/app/actions/organization_delete.rb @@ -11,6 +11,9 @@ def initialize(space_deleter, user_audit_info) def delete(org_dataset) org_dataset.each do |org| + shared_errors = shared_domain_errors(org) + return shared_errors unless shared_errors.empty? + mark_deleting_for_account_cleanup(org) errs = @space_deleter.delete(org.spaces_dataset) unless errs.empty? @@ -50,6 +53,14 @@ def timeout_error(dataset) private + def shared_domain_errors(org) + domains = org.owned_private_domains.select(&:shared_with_any_orgs?) + return [] if domains.empty? + + details = domains.map { |domain| "Domain '#{domain.name}' is shared with other organizations. Unshare before deleting." }.join("\n") + [CloudController::Errors::ApiError.new_from_details('OrganizationDeletionFailed', org.name, details)] + end + def mark_deleting_for_account_cleanup(org) return unless ServiceAccountModel.where(organization_id: org.id).any? From 9b4e7b66d2acde8b3977a9dc835ca62c4e1e9b1c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:31:42 +0100 Subject: [PATCH 150/200] Refresh owned domains when guarding account cleanup and preserve existing job ordering --- app/actions/organization_delete.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/actions/organization_delete.rb b/app/actions/organization_delete.rb index 0d1214a5d43..abf81502eed 100644 --- a/app/actions/organization_delete.rb +++ b/app/actions/organization_delete.rb @@ -54,7 +54,9 @@ def timeout_error(dataset) private def shared_domain_errors(org) - domains = org.owned_private_domains.select(&:shared_with_any_orgs?) + return [] unless ServiceAccountModel.where(organization_id: org.id).any? + + domains = org.owned_private_domains_dataset.all.select(&:shared_with_any_orgs?) return [] if domains.empty? details = domains.map { |domain| "Domain '#{domain.name}' is shared with other organizations. Unshare before deleting." }.join("\n") From ead0dd1cba5fae1a7b8f42b41500d4c5c5c08c85 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:40:01 +0100 Subject: [PATCH 151/200] Document org-owned account APIs and align acceptance fixtures --- .../v3/service_accounts_controller.rb | 20 +- .../api_resources/_service_accounts.md | 284 ++++++++---------- spec/request/organization_quotas_spec.rb | 5 + .../actions/service_account_provision_spec.rb | 2 +- .../runtime/service_account_ownership_spec.rb | 2 +- 5 files changed, 143 insertions(+), 170 deletions(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index de527ac44f3..fcd5afb2b3c 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -61,12 +61,7 @@ def create limit = space.organization.quota_definition.total_service_accounts unprocessable!('Organization service account quota exceeded') if limit != -1 && ServiceAccountModel.where(organization_id: space.organization_id).count >= limit ServiceAccountCreationBudget.consume(current_user, exempt: permission_queryer.can_write_globally?) do - if reuse - lifecycle_conflict!('live account name is already in use') if ServiceAccountModel.where(organization_id: space.organization_id, name: message.name).any? - previous_guid = ServiceAccountModel.db[:service_account_name_deletions]. - where(organization_guid: space.organization.guid, name: message.name).order(Sequel.desc(:deleted_at)).first&.fetch(:account_guid) - ServiceAccountModel.db[:service_account_names].where(organization_id: space.organization_id, name: message.name).delete - end + previous_guid = release_reservation_for_reuse(space, message) if reuse account = ServiceAccountModel.create(name: message.name, space: space) apply_metadata(account, message) Repositories::ServiceAccountEventRepository.record(account, 'create', user_audit_info, reuse_name: reuse, previous_account_guid: previous_guid) @@ -122,6 +117,14 @@ def destroy private + def release_reservation_for_reuse(space, message) + lifecycle_conflict!('live account name is already in use') if ServiceAccountModel.where(organization_id: space.organization_id, name: message.name).any? + history = ServiceAccountModel.db[:service_account_name_deletions]. + where(organization_guid: space.organization.guid, name: message.name).order(Sequel.desc(:deleted_at)).first + ServiceAccountModel.db[:service_account_names].where(organization_id: space.organization_id, name: message.name).delete + history&.fetch(:account_guid) + end + def authorize_creation!(space, message) reuse = message.reuse_name == true manager = permission_queryer.can_write_globally? || space.organization.managers_dataset.where(users__id: current_user.id).any? @@ -227,11 +230,6 @@ def readable_account 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) diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md index 44dd64e2866..e6643afa4ba 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -1,172 +1,142 @@ # Service accounts (experimental) -## Org-ownership transition - -The registry now persists an immutable owning organization and creates an initial -space-entitlement row atomically with every account. Migration -`20261109121000` backfills the organization from the existing space and entitles -that space. UUIDs, names, client IDs, SANs, state and workload references are -preserved; org prefixes are not generated or allocated by migration. - -This is the additive persistence step toward the org-owned RFC. The legacy space -column, API representation, authorization and space-deletion behavior described -below remain until their entitlement-aware cutover. Further entitlements are not -exposed by this step. Do not enable cross-space use with this intermediate version. -The required organization column means older CAPI writers cannot create accounts -after migration; coordinate API/worker upgrades or keep account creation disabled -during the transition. Downgrade removes the new owner/entitlement data while -retaining the original account records and space relationships. - -## Current API compatibility behavior - -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. +Service accounts belong to an immutable owning organization. Apps in explicitly +entitled spaces of that org can share an account; each app has zero or one desired +account. Assignment does not grant Cloud Controller roles, cloud grants or route +permissions. -| 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 }` | +## Names and identity -Minimal creation body (permitted roles: owning-space manager or platform admin): +An Admin allocates `service_account_prefix` on the V3 organization at creation or +once later. The prefix is foundation-unique, independent of the display name, +immutable, and reserved after org deletion. No prefix is generated automatically. +Prefixes and account names are lowercase RFC 1123 labels, 3–63 characters. -```json -{ - "name": "payments-worker", - "relationships": { "space": { "data": { "guid": "SPACE_GUID" } } } -} -``` +For prefix `acme-payments` and name `payments-worker`: + +| Identifier | Value | +| --- | --- | +| OAuth client/subject | `cf:service-account:acme-payments:payments-worker` | +| Certificate DNS SAN | `payments-worker.acme-payments.svc.identity` | +| Route source | `cf:svc:acme-payments:payments-worker` | -Optional creation fields are `description` and standard `metadata.labels` and -`metadata.annotations`. Metadata updates use the normal merge/removal semantics. +Names are unique within the org, including permanent deletion reservations. +Deleting records the deletion timestamp separately from account creation/audit +retention. Historical dates remain unknown. Org Managers/Admins may explicitly +create with `reuse_name: true`: fresh UUID and initial entitlement, no restored +bindings, roles or grants. Live collisions and quotas still apply. The reused +subject/SAN may inherit external grants or accept still-valid old certificates. -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. +## APIs and permissions -Example account object: +Creation uses the target space to choose the org and grant the initial entitlement: ```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" } - } + "relationships": { "space": { "data": { "guid": "SPACE_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, including any registration difference, 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. -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 - -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`: dedicated UAA - management credentials. Configure namespace protection independently in UAA. -- Worker-only `identity_ca_ref` (required): name of an operator-defined UAA trust - bundle (`uaa.mtls.trust_bundles`, UAA with named bundle support) containing the - instance identity CA. Clients reference the bundle; rotate the CA by changing the - bundle's contents in UAA (old+new, then new). Inline CA certificates are not - supported, and changing the configured bundle name makes existing clients collide. - -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. +Optional fields: `description`, `metadata`, `reuse_name`. Identity/state and owning +org are platform controlled. Account representations expose `organization` and +`spaces` relationships. The nullable legacy `space` relationship is retained for +transitional clients; use `organization`/`spaces` for new clients. + +| Method | Path | Behavior | +| --- | --- | --- | +| POST | `/v3/service_accounts` | Space Developer/Admin creates a reserved account; requires org prefix | +| GET | `/v3/service_accounts` | Entitled-space readers and Org Managers; `names`, `space_guids`, `organization_guids` filters | +| GET | `/v3/service_accounts/:guid` | Read account; Org Managers also see zero-entitlement accounts | +| GET | `/v3/service_accounts/:guid/apps` | Read assigned apps visible to caller | +| PATCH | `/v3/service_accounts/:guid` | Metadata or async enable/disable | +| DELETE | `/v3/service_accounts/:guid` | Async unused-account cleanup | +| GET | `/v3/service_accounts/:guid/relationships/spaces` | List entitled space GUIDs | +| POST | `/v3/service_accounts/:guid/relationships/spaces` | Add `{ "data": [{ "guid": "SPACE_GUID" }] }` | +| DELETE | `/v3/service_accounts/:guid/relationships/spaces/:space_guid` | Revoke entitlement | +| GET/PATCH | `/v3/apps/:app_guid/relationships/service_account` | Read/assign `{ "data": { "guid": "ACCOUNT_GUID" } }`, clear with `null` | + +Lifecycle management: Org Manager/Admin or Developer in an entitled active space. +Entitlement addition: Org Manager/Admin, or Developer of both an entitled source +space and destination. Cross-org entitlements are rejected. Developers may revoke +their space unless it is the last entitlement; Org Managers/Admins can revoke the +last. Desired assignments, active process/task snapshots and identity-bearing +service bindings block revocation. App assignment requires existing app-write +permission, same-org entitlement and allocated org prefix. + +Org quota `service_accounts.total_service_accounts` is a nonnegative integer or +`null` for unlimited. It counts existing accounts including disabled/reserved; +bindings/deleted names do not consume capacity. Creation checks serialize by org. +The independent optional rolling seven-day creation budget spans the foundation +per authenticated principal; Admins are exempt. Failures do not consume budget, +and deletion does not refund it. `service_account_creation_limit: -1` is unlimited. + +## Provisioning, attribution and lifecycle + +First bind queues UAA provisioning and returns 202 with a pollable job; ready +account binds return 200. Wait before launching; bind/unbind never restarts apps. +Instances retain launch identity through renewal; tasks capture desired assignment; +staging has no account identity. Independent instance credentials remain intact. + +The managed client is secretless, client_credentials-only, bearer policy explicitly +false for certificate binding, TTL 300, named CA bundle reference, exact SAN and +required owning-org claim. CN/OU mappings provide instance/app/space/org attribution. +The principal starts roleless; explicit CAPI roles plus OAuth authorities authorize +CAPI access. Any registration difference is a collision, not adopted/reconciled. + +Disable removes the managed registration asynchronously, stopping new token +issuance when effective. Existing JWTs, certificate routes and external sessions +remain valid until their own expiry/revocation. Enable recreates the registration +and retains resource roles. Delete requires no desired/active/binding references. +Space deletion removes workloads/entitlement but retains the org account and +authentication. Org deletion cleans workloads, clients, principals/grants and +accounts retryably; failed cleanup retains a deleting org. Names/prefixes persist. + +## Broker and routes + +App service bindings capture the assigned account; ready/entitled identity is +sent as platform `context.service_account {issuer,subject}`. Existing bindings +without an account snapshot and service keys carry no account identity. Remove +identity-bearing bindings before reassigning. Broker implementations own shared +grant lifetime/reference counting and cleanup across entitled spaces. + +Qualified account route rules resolve to UUID references, are readable-source +authorized, and are removed on account deletion. Domain org/space restrictions +remain caller restrictions enforced by routing. The source relationship and +`include=source` return readable account data. Disabling tokens does not revoke +certificate-only route access. + +## Rollout, migration and diagnostics + +BOSH `cc.service_accounts` properties: + +- `provisioning_enabled`, `runtime_enabled`: default false. +- `runtime_contract_version`: default 0; set 2 only when all BBS/rep/cells preserve + prefix/name and issue qualified SANs. Qualified launches fail closed otherwise. +- `router_contract_version`: default 0; set 2 only when all routers enforce + qualified account sources. Account rules fail closed on creation otherwise. +- `token_endpoint`: HTTPS UAA mTLS endpoint, no credentials/query/fragment. +- Worker-only `management_client_id`, `management_client_secret`, `identity_ca_ref`. + Inline CA certificates are unsupported. Rotate named bundle contents in UAA. +- Worker-only optional `allowed_resources_ref`: requires UAA named-resource-policy + support (#4125). Leave unset until supported; CAPI does not copy audience lists. + +Existing POC accounts backfill org/initial entitlement without changing UUID, +name, client ID or SAN. Immutable `identity_prefix` snapshots distinguish new +qualified accounts from legacy accounts; allocating an org prefix does not rewrite +old clients. Global legacy reservations protect old unqualified subjects. Retire +legacy identities deliberately, removing external grants and allowing certificate +expiry. Existing client registrations need deliberate profile migration/retirement; +strict ownership checking does not overwrite old registration profiles. + +Coordinate writers during schema migration: old writers cannot supply required org +ownership. Downgrade refuses populated org-local identities or duplicate org-local +reservations rather than losing isolation. Retain backups and pinned releases. +`ServiceAccountModel.reservation_statistics` provides counts, age buckets and unknown +historical dates for redacted operational collection; compare timestamped samples +for growth. No automatic reservation release is implemented. + +Remaining external acceptance: qualified Diego/router rollout, UAA resource-policy +support, min(300s, remaining certificate lifetime) issuance, management namespace +confinement, provider audience interoperability and live upgrade/renewal/rotation. +Operator contract gates are explicit assertions, not automatic cell negotiation. diff --git a/spec/request/organization_quotas_spec.rb b/spec/request/organization_quotas_spec.rb index 06d6e3165c1..c5646f9a4a6 100644 --- a/spec/request/organization_quotas_spec.rb +++ b/spec/request/organization_quotas_spec.rb @@ -31,6 +31,7 @@ module VCAP::CloudController created_at: iso8601, updated_at: iso8601, name: params[:name], + service_accounts: { total_service_accounts: nil }, apps: { total_memory_in_mb: nil, per_process_memory_in_mb: nil, @@ -114,6 +115,7 @@ module VCAP::CloudController created_at: iso8601, updated_at: iso8601, name: 'org1', + service_accounts: { total_service_accounts: nil }, apps: { total_memory_in_mb: 5120, per_process_memory_in_mb: 1024, @@ -344,6 +346,7 @@ module VCAP::CloudController created_at: iso8601, updated_at: iso8601, name: params[:name], + service_accounts: { total_service_accounts: nil }, apps: { total_memory_in_mb: 5120, per_process_memory_in_mb: 1024, @@ -622,6 +625,7 @@ def generate_org_quota_single_response(list_of_orgs) created_at: iso8601, updated_at: iso8601, name: organization_quota.name, + service_accounts: { total_service_accounts: nil }, apps: { total_memory_in_mb: 20_480, per_process_memory_in_mb: nil, @@ -671,6 +675,7 @@ def generate_default_org_quota_response(global_read) created_at: iso8601, updated_at: iso8601, name: default_quota.name, + service_accounts: { total_service_accounts: nil }, apps: { total_memory_in_mb: 10_240, per_process_memory_in_mb: nil, diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index bc7e736931f..dd940e216f0 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -135,7 +135,7 @@ module VCAP::CloudController 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(headers: { 'Authorization' => 'bearer test-token' }) { |request| Oj.load(request.body) == registration }. + with(headers: { 'Authorization' => 'bearer test-token' }) { |request| Oj.load(request.body) == registration }. to_return(status: 201, headers: response_headers, body: registration.to_json) action.provision(account) diff --git a/spec/unit/models/runtime/service_account_ownership_spec.rb b/spec/unit/models/runtime/service_account_ownership_spec.rb index a1ea7722d47..86d59c7b32b 100644 --- a/spec/unit/models/runtime/service_account_ownership_spec.rb +++ b/spec/unit/models/runtime/service_account_ownership_spec.rb @@ -70,7 +70,7 @@ module VCAP::CloudController it 'counts an orphan reservation as unknown even when another org uses the same local name' do other_org = create(:organization, service_account_prefix: 'stats-team') ServiceAccountModel.create(name: 'shared-name', space: create(:space, organization: other_org)) - ServiceAccountModel.db[:service_account_names].insert(name: 'shared-name', organization_id: space.organization_id) + ServiceAccountModel.db[:service_account_names].insert(name: 'shared-name', organization_id: space.organization_id) # rubocop:disable Rails/SkipsModelValidations -- Historical orphan fixture. expect(ServiceAccountModel.reservation_statistics[:unknown_deletion_times]).to eq(1) end end From a98f8a0f5b7537d07d7fe38b49cb423edbecb4aa Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:43:02 +0100 Subject: [PATCH 152/200] Cover quota lowering with cached organization associations --- .../actions/service_account_quota_concurrency_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/unit/actions/service_account_quota_concurrency_spec.rb b/spec/unit/actions/service_account_quota_concurrency_spec.rb index fedd418f7ab..58248e3ccf8 100644 --- a/spec/unit/actions/service_account_quota_concurrency_spec.rb +++ b/spec/unit/actions/service_account_quota_concurrency_spec.rb @@ -2,6 +2,15 @@ module VCAP::CloudController RSpec.describe 'Service account quota concurrency' do + it 'checks the current quota rather than an association cached before quota lowering' do + org = create(:organization, service_account_prefix: 'race-team') + space = create(:space, organization: org) + cached_quota = org.quota_definition + account = ServiceAccountModel.new(name: 'worker-app', space: space, organization: org) + QuotaDefinition.where(id: cached_quota.id).update(total_service_accounts: 0) + expect { account.save }.to raise_error(Sequel::ValidationFailed, /quota/) + end + it 'serializes direct account creations within an org', isolation: :truncation do org = create(:organization, service_account_prefix: 'race-team') org.quota_definition.update(total_service_accounts: 1) From 7713454376f7ae5d93a8d9801f408f07c5707574 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:45:24 +0100 Subject: [PATCH 153/200] Test suspended source spaces cannot authorize entitlement expansion (red) --- spec/request/service_account_entitlements_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index ebb7bdd14f0..1e10f011d33 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -165,5 +165,15 @@ def entitle(destination, headers) expect(last_response.status).to eq(200) expect(account.reload.entitled_spaces).to contain_exactly(target) end + + it 'does not use a suspended source space to authorize entitlement expansion' do + headers = auth('space_developer') + target.add_developer(user) + account + space.update(status: Space::SUSPENDED) + entitle(target, headers) + expect(last_response.status).to eq(403) + expect(account.reload.entitled_spaces).to contain_exactly(space) + end end end From 52068ab4ddbb87b17de6f2ab18ef49361228e6d5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 9 Nov 2026 17:47:12 +0100 Subject: [PATCH 154/200] Require current active source entitlements for developer sharing --- app/controllers/v3/service_accounts_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index fcd5afb2b3c..a64bd099258 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -165,7 +165,7 @@ def entitle space = Space.first(guid: guid) unprocessable!('Space must belong to the account organization') unless space && space.organization_id == account.organization_id unprocessable!('Space is being deleted') if space.deleting? - unauthorized! unless org_manager?(account) || (developer?(space) && account.entitled_spaces.any? { |source| developer?(source) }) + unauthorized! unless can_entitle?(account, space) require_writable_space!(space) account.add_entitled_space(space) unless account.entitled_spaces_dataset.where(spaces__guid: space.guid).any? Repositories::ServiceAccountEventRepository.record(account, 'entitle', user_audit_info, space_guid: space.guid) @@ -192,6 +192,10 @@ def unentitle private + def can_entitle?(account, space) + org_manager?(account) || (developer?(space) && account.entitled_spaces_dataset.all.any? { |source| source.active? && developer?(source) }) + end + def entitlement_guids data = hashed_params[:body]['data'] || hashed_params[:body][:data] unprocessable!('Data must be an array of space GUIDs') unless data.is_a?(Array) From fd9af8448f9f8b8dc1a880a6eb1925830959ae28 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 10 Nov 2026 08:26:09 +0100 Subject: [PATCH 155/200] Test account route policies need no router-version setting (red) --- spec/request/service_account_route_policies_spec.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/spec/request/service_account_route_policies_spec.rb b/spec/request/service_account_route_policies_spec.rb index 3f1ab26b736..c9aa8366838 100644 --- a/spec/request/service_account_route_policies_spec.rb +++ b/spec/request/service_account_route_policies_spec.rb @@ -3,7 +3,6 @@ module VCAP::CloudController RSpec.describe 'Service account route sources' do it 'refuses a grant to an account whose deletion has begun' do - TestConfig.override(service_account_router_contract_version: 2) org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) ServiceAccountModel.create(name: 'route-worker', space: space, status: 'deleting', enabled: false) @@ -14,7 +13,7 @@ module VCAP::CloudController expect(last_response.status).to eq(422) end - it 'refuses account rules until qualified routers are explicitly enabled' do + it 'creates account rules without an extra router-version configuration' do org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) ServiceAccountModel.create(name: 'route-worker', space: space) @@ -22,12 +21,11 @@ module VCAP::CloudController route = create(:route, space: space, domain: domain) body = { source: 'cf:svc:route-team:route-worker', relationships: { route: { data: { guid: route.guid } } } } post '/v3/route_policies', body.to_json, admin_headers_for(create(:user)) - expect(last_response.status).to eq(422) - expect(parsed_response.dig('errors', 0, 'detail')).to include('router contract') + expect(last_response.status).to eq(201) + expect(parsed_response['source']).to eq(body[:source]) end it 'rejects granting an unreadable account and filters qualified sources without parser failures' do - TestConfig.override(service_account_router_contract_version: 2) org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) hidden_space = create(:space, organization: create(:organization, service_account_prefix: 'hidden-team')) @@ -45,7 +43,6 @@ module VCAP::CloudController end it 'resolves a qualified source to an immutable account reference and removes grants on deletion' do - TestConfig.override(service_account_router_contract_version: 2) org = create(:organization, service_account_prefix: 'route-team') space = create(:space, organization: org) account = ServiceAccountModel.create(name: 'route-worker', space: space) From f9d5880c094339997a858f80661811f7cd1e8293 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 10 Nov 2026 08:27:56 +0100 Subject: [PATCH 156/200] Remove router contract gate and require routing-first release sequencing --- app/controllers/v3/route_policies_controller.rb | 2 -- .../source/includes/api_resources/_service_accounts.md | 10 ++++++++-- lib/cloud_controller/config_schemas/api_schema.rb | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 1e2ed7756d2..fc9bbbeb726 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -93,8 +93,6 @@ def validate_account_source!(source) match = source.match(RoutePolicy::SERVICE_ACCOUNT_SOURCE_REGEX) return unless match - unprocessable!('Service account router contract version 2 is not enabled') unless Config.config.get(:service_account_router_contract_version) == 2 - account = ServiceAccountModel.first(identity_prefix: match[1], name: match[2]) readable = account && (permission_queryer.can_read_globally? || account.organization.managers_dataset.where(users__id: current_user.id).any? || diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md index e6643afa4ba..28f3392091a 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -113,8 +113,6 @@ BOSH `cc.service_accounts` properties: - `provisioning_enabled`, `runtime_enabled`: default false. - `runtime_contract_version`: default 0; set 2 only when all BBS/rep/cells preserve prefix/name and issue qualified SANs. Qualified launches fail closed otherwise. -- `router_contract_version`: default 0; set 2 only when all routers enforce - qualified account sources. Account rules fail closed on creation otherwise. - `token_endpoint`: HTTPS UAA mTLS endpoint, no credentials/query/fragment. - Worker-only `management_client_id`, `management_client_secret`, `identity_ca_ref`. Inline CA certificates are unsupported. Rotate named bundle contents in UAA. @@ -140,3 +138,11 @@ Remaining external acceptance: qualified Diego/router rollout, UAA resource-poli support, min(300s, remaining certificate lifetime) issuance, management namespace confinement, provider audience interoperability and live upgrade/renewal/rotation. Operator contract gates are explicit assertions, not automatic cell negotiation. + +Ship qualified account-source support in routing-release before releasing the +CAPI producer changes, and pin/deploy that routing version first. Existing routers +skip unknown source types and deny when no recognized source matches; existing +recognized rules retain their OR semantics. No extra router-version setting is +required. cf-deployment's global `update.serial: false` permits parallel instance +group updates and does not guarantee router-before-CAPI completion within one +deployment. Verify the routing-first release/deployment sequence explicitly. diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index a7af2f45ec5..78d63e2a0e9 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -33,7 +33,6 @@ class ApiSchema < VCAP::Config optional(:service_account_provisioning_enabled) => bool, optional(:service_account_runtime_enabled) => bool, optional(:service_account_runtime_contract_version) => Integer, - optional(:service_account_router_contract_version) => Integer, optional(:service_account_creation_limit) => Integer, optional(:service_account_token_endpoint) => String, optional(:service_account_provisioning) => { From ba9528479c298ee095e8916ce6a504c059d59498 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 12:28:15 +0100 Subject: [PATCH 157/200] Align regression expectations with account prefixes and entitlements --- spec/request/apps_spec.rb | 3 +++ spec/request/roles_spec.rb | 3 +++ spec/request/spaces_spec.rb | 3 +++ ...service_credential_binding_app_create_spec.rb | 2 +- .../runtime/service_account_assignment_spec.rb | 16 +++++++++------- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb index 1fb50217fc8..e5e1092e956 100644 --- a/spec/request/apps_spec.rb +++ b/spec/request/apps_spec.rb @@ -1320,6 +1320,7 @@ 'created_at' => iso8601, 'updated_at' => iso8601, 'name' => org1.name, + 'service_account_prefix' => org1.service_account_prefix, 'metadata' => { 'labels' => {}, 'annotations' => {} @@ -1346,6 +1347,7 @@ 'created_at' => iso8601, 'updated_at' => iso8601, 'name' => org2.name, + 'service_account_prefix' => org2.service_account_prefix, 'suspended' => false, 'metadata' => { 'labels' => {}, @@ -1631,6 +1633,7 @@ 'created_at' => iso8601, 'updated_at' => iso8601, 'name' => org.name, + 'service_account_prefix' => org.service_account_prefix, 'metadata' => { 'labels' => {}, 'annotations' => {} diff --git a/spec/request/roles_spec.rb b/spec/request/roles_spec.rb index 50c09e41144..5f155b502c3 100644 --- a/spec/request/roles_spec.rb +++ b/spec/request/roles_spec.rb @@ -1410,6 +1410,7 @@ def make_space_role_for_current_user(type) created_at: iso8601, updated_at: iso8601, name: org.name, + service_account_prefix: org.service_account_prefix, suspended: false, relationships: { quota: { @@ -1540,6 +1541,7 @@ def make_space_role_for_current_user(type) created_at: iso8601, updated_at: iso8601, name: another_org.name, + service_account_prefix: another_org.service_account_prefix, suspended: false, relationships: { quota: { @@ -1748,6 +1750,7 @@ def make_space_role_for_current_user(type) created_at: iso8601, updated_at: iso8601, name: org.name, + service_account_prefix: org.service_account_prefix, suspended: false, relationships: { quota: { diff --git a/spec/request/spaces_spec.rb b/spec/request/spaces_spec.rb index c2c4a8aa503..a1c9f2020fe 100644 --- a/spec/request/spaces_spec.rb +++ b/spec/request/spaces_spec.rb @@ -160,6 +160,7 @@ 'created_at' => iso8601, 'updated_at' => iso8601, 'name' => org.name, + 'service_account_prefix' => org.service_account_prefix, 'suspended' => false, 'metadata' => { 'labels' => {}, @@ -414,6 +415,7 @@ 'created_at' => iso8601, 'updated_at' => iso8601, 'name' => org1.name, + 'service_account_prefix' => org1.service_account_prefix, 'suspended' => false, 'metadata' => { 'labels' => {}, @@ -440,6 +442,7 @@ 'created_at' => iso8601, 'updated_at' => iso8601, 'name' => org2.name, + 'service_account_prefix' => org2.service_account_prefix, 'suspended' => false, 'metadata' => { 'labels' => {}, diff --git a/spec/unit/actions/service_credential_binding_app_create_spec.rb b/spec/unit/actions/service_credential_binding_app_create_spec.rb index 15c9507b7b3..0847efaeeec 100644 --- a/spec/unit/actions/service_credential_binding_app_create_spec.rb +++ b/spec/unit/actions/service_credential_binding_app_create_spec.rb @@ -178,7 +178,7 @@ module V3 # We mock that a second binding is created after the first one acquires a lock and expect an `UnprocessableCreate` error. allow(app).to receive(:lock!).and_wrap_original do |m, *args, &block| m.call(*args, &block) - create(:service_binding, service_instance:, app:) + create(:service_binding, service_instance: service_instance, app: app.class.find(guid: app.guid)) end expect do diff --git a/spec/unit/models/runtime/service_account_assignment_spec.rb b/spec/unit/models/runtime/service_account_assignment_spec.rb index 4d1cee6e97e..550e7f69ced 100644 --- a/spec/unit/models/runtime/service_account_assignment_spec.rb +++ b/spec/unit/models/runtime/service_account_assignment_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' module VCAP::CloudController - RSpec.describe 'Space-owned service account app assignments' do + RSpec.describe 'Entitled 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) } @@ -10,7 +10,7 @@ module VCAP::CloudController expect(app.service_account).to be_nil end - it 'allows multiple apps in the owning space to share one account' do + it 'allows multiple apps in an entitled space to share one account' do other_app = create(:app_model, space: space) app.update(service_account: account) other_app.update(service_account: account) @@ -23,20 +23,22 @@ module VCAP::CloudController expect(app.reload.service_account).to be_nil end - it 'rejects assignment from another space in the same organization' do + it 'rejects assignment without an entitlement 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.update(service_account: other_account) }.to raise_error(Sequel::ValidationFailed, /entitled to the app space in the same organization/) expect(app.reload.service_account).to be_nil end - it 'rejects moving a bound app to another space' do + it 'rejects moving a bound app to an unentitled space' do app.update(service_account: account) - expect { app.update(space: create(:space, organization: space.organization)) }.to raise_error(Sequel::ValidationFailed, /owning space/) + expect do + app.update(space: create(:space, organization: space.organization)) + end.to raise_error(Sequel::ValidationFailed, /entitled to the app space in the same organization/) expect(app.reload.space).to eq(space) end - it 'does not permit changing an account owner space' do + it 'does not permit changing the legacy initial space' do expect { account.update(space: create(:space)) }.to raise_error(Sequel::ValidationFailed, /immutable/) expect(account.reload.space).to eq(space) end From 98bab7d55149d0151f7baff1ec889606d3d3ffb8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 12:45:51 +0100 Subject: [PATCH 158/200] Test concurrent account provisioning after permission snapshots (red) --- spec/unit/actions/app_assign_service_account_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/unit/actions/app_assign_service_account_spec.rb b/spec/unit/actions/app_assign_service_account_spec.rb index 84b6812a4d9..847eeb0d430 100644 --- a/spec/unit/actions/app_assign_service_account_spec.rb +++ b/spec/unit/actions/app_assign_service_account_spec.rb @@ -55,11 +55,23 @@ module VCAP::CloudController space.organization.add_user(writer) space.add_developer(writer) permission_queryer = Permissions.new(writer) + permission_checks = Queue.new + continue_assignments = Queue.new + allow(permission_queryer).to receive(:can_write_to_active_space?).and_wrap_original do |method, *args| + result = method.call(*args) + # Both transactions must read before either locks the shared account. On MySQL, + # this establishes repeatable-read snapshots that cannot see the other job. + permission_checks << true + continue_assignments.pop + result + end 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 + Timeout.timeout(10) { ids.size.times { permission_checks.pop } } + ids.size.times { continue_assignments << true } jobs = threads.map(&:value) expect(jobs.map(&:guid).uniq.size).to eq(1) expect(PollableJobModel.where(resource_guid: account.guid).count).to eq(1) From 3c52afde7be5779337e66df244f3ea3ceeb7b516 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 12:47:34 +0100 Subject: [PATCH 159/200] Use current locking read when reusing account provisioning jobs --- app/actions/app_assign_service_account.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 3a6db84b4a5..8e87ccd4e87 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -63,7 +63,7 @@ def validate_ready!(account, provision) end def provision_account(account) - job = PollableJobModel.first(resource_guid: account.guid, operation: 'service_account.provision', state: %w[PROCESSING POLLING]) + job = PollableJobModel.where(resource_guid: account.guid, operation: 'service_account.provision', state: %w[PROCESSING POLLING]).for_update.first return job if job account.update(status: 'reconciling') From 949f62e12848991c56199caa562da1ef976c8700 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 13:02:33 +0100 Subject: [PATCH 160/200] Test MySQL budget snapshots and namespace rollback indexes (red) --- .../service_account_namespace_rollback_spec.rb | 14 ++++++++++++++ .../service_account_creation_budget_spec.rb | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/spec/migrations/service_account_namespace_rollback_spec.rb b/spec/migrations/service_account_namespace_rollback_spec.rb index ac47c51a1bb..a8804a829bb 100644 --- a/spec/migrations/service_account_namespace_rollback_spec.rb +++ b/spec/migrations/service_account_namespace_rollback_spec.rb @@ -7,4 +7,18 @@ expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_109_122_999) }.not_to raise_error Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) end + + it 'allows reverting an empty transition after MySQL has selected the composite index for the org foreign key' do + db = Sequel::Model.db + skip 'MySQL chooses foreign-key supporting indexes' unless db.database_type == :mysql + + db.alter_table(:service_accounts) do + drop_foreign_key [:organization_id], name: :fk_service_accounts_organization + drop_index :organization_id, name: :fk_service_accounts_organization, concurrently: false if db.indexes(:service_accounts).key?(:fk_service_accounts_organization) + add_foreign_key [:organization_id], :organizations, name: :fk_service_accounts_organization + end + + expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_109_122_999) }.not_to raise_error + Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) + end end diff --git a/spec/unit/actions/service_account_creation_budget_spec.rb b/spec/unit/actions/service_account_creation_budget_spec.rb index 32e24508b08..377aadf8d65 100644 --- a/spec/unit/actions/service_account_creation_budget_spec.rb +++ b/spec/unit/actions/service_account_creation_budget_spec.rb @@ -8,11 +8,16 @@ module VCAP::CloudController principal = create(:user) spaces = create_list(:space, 2) gate = Queue.new + snapshots = Queue.new + consume_budget = 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 + authenticated_principal = User.first(guid: principal.guid) + snapshots << true + consume_budget.pop + described_class.consume(authenticated_principal, exempt: false) do ServiceAccountModel.create(name: "worker-#{index}", space: space) end end @@ -22,6 +27,8 @@ module VCAP::CloudController end end threads.size.times { gate << true } + Timeout.timeout(10) { threads.size.times { snapshots.pop } } + threads.size.times { consume_budget << true } expect(threads.map(&:value)).to contain_exactly(:created, 'ServiceAccountCreationLimitExceeded') expect(ServiceAccountModel.count).to eq(1) From ef91b92b9500fb9c87e67beffcc5393319083c62 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 13:07:11 +0100 Subject: [PATCH 161/200] Test full empty account-assignment rollback on MySQL (red) --- spec/migrations/service_account_namespace_rollback_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/migrations/service_account_namespace_rollback_spec.rb b/spec/migrations/service_account_namespace_rollback_spec.rb index a8804a829bb..77d99351b99 100644 --- a/spec/migrations/service_account_namespace_rollback_spec.rb +++ b/spec/migrations/service_account_namespace_rollback_spec.rb @@ -21,4 +21,11 @@ expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_109_122_999) }.not_to raise_error Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) end + + it 'allows reverting empty account assignments and restoring their foreign key' do + db = Sequel::Model.db + expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_005_120_000) }.not_to raise_error + Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) + expect(db.foreign_key_list(:apps)).to include(hash_including(columns: [:service_account_guid], table: :service_accounts)) + end end From 498d40b4c9df21a1f0149d95bbcd14586cf71186 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 13:20:10 +0100 Subject: [PATCH 162/200] Fix MySQL account creation snapshots and rollback index dependencies --- app/actions/service_account_creation_budget.rb | 4 +++- db/migrations/20261005120100_add_app_service_account.rb | 2 +- ...123000_scope_service_account_names_to_organizations.rb | 6 ++++++ .../migrations/service_account_namespace_rollback_spec.rb | 8 +------- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/actions/service_account_creation_budget.rb b/app/actions/service_account_creation_budget.rb index 48fd6ee9e68..8eb6877c087 100644 --- a/app/actions/service_account_creation_budget.rb +++ b/app/actions/service_account_creation_budget.rb @@ -11,7 +11,9 @@ def self.consume(principal, exempt:) 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 + # MySQL permission/authentication reads may have established an older + # repeatable-read snapshot before the principal lock was acquired. + used = creations.where(principal_guid: principal.guid).where { created_at > now - WINDOW }.select(:principal_guid).for_update.all.size raise CloudController::Errors::V3::ApiError.new_from_details('ServiceAccountCreationLimitExceeded') if limit.between?(0, used) result = yield diff --git a/db/migrations/20261005120100_add_app_service_account.rb b/db/migrations/20261005120100_add_app_service_account.rb index 6bc61c54722..7f7c87a7ec4 100644 --- a/db/migrations/20261005120100_add_app_service_account.rb +++ b/db/migrations/20261005120100_add_app_service_account.rb @@ -22,11 +22,11 @@ end down do + alter_table(:apps) { drop_foreign_key [:service_account_guid], name: :fk_apps_service_account_guid } 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 diff --git a/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb b/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb index 812de42e86d..61ce9fd64de 100644 --- a/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb +++ b/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb @@ -28,6 +28,12 @@ duplicate_names = from(:service_account_names).group(:name).having { count.function.* > 1 }.any? raise Sequel::Error.new('Org-local reservations cannot be collapsed into a global namespace') if duplicate_names + # InnoDB may discard its original FK index when the composite index is added. + # Restore a supporting index before removing the composite one on rollback. + if database_type == :mysql && indexes(:service_accounts).values.none? { |index| index[:columns] == [:organization_id] } + alter_table(:service_accounts) { add_index :organization_id, name: :fk_service_accounts_organization, concurrently: false } + end + alter_table :service_accounts do drop_index %i[organization_id name], name: :service_accounts_org_name_unique, concurrently: false drop_column :identity_prefix diff --git a/spec/migrations/service_account_namespace_rollback_spec.rb b/spec/migrations/service_account_namespace_rollback_spec.rb index 77d99351b99..8223bc8431e 100644 --- a/spec/migrations/service_account_namespace_rollback_spec.rb +++ b/spec/migrations/service_account_namespace_rollback_spec.rb @@ -1,13 +1,6 @@ require 'spec_helper' RSpec.describe 'Service account namespace rollback', isolation: :truncation do - it 'allows reverting an empty namespace transition' do - db = Sequel::Model.db - expect(db[:service_accounts].count).to eq(0) - expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_109_122_999) }.not_to raise_error - Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) - end - it 'allows reverting an empty transition after MySQL has selected the composite index for the org foreign key' do db = Sequel::Model.db skip 'MySQL chooses foreign-key supporting indexes' unless db.database_type == :mysql @@ -24,6 +17,7 @@ it 'allows reverting empty account assignments and restoring their foreign key' do db = Sequel::Model.db + expect(db[:service_accounts].count).to eq(0) expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_005_120_000) }.not_to raise_error Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) expect(db.foreign_key_list(:apps)).to include(hash_including(columns: [:service_account_guid], table: :service_accounts)) From 7f69c1db98989f6c3e2419b0d7256ca933839b29 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 14:25:07 +0100 Subject: [PATCH 163/200] Test account assignment after concurrent entitlement revocation (red) --- .../app_assign_service_account_spec.rb | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/spec/unit/actions/app_assign_service_account_spec.rb b/spec/unit/actions/app_assign_service_account_spec.rb index 847eeb0d430..401eba61ac0 100644 --- a/spec/unit/actions/app_assign_service_account_spec.rb +++ b/spec/unit/actions/app_assign_service_account_spec.rb @@ -47,6 +47,42 @@ module VCAP::CloudController expect { action.assign(app, account) }.to raise_error(AppAssignServiceAccount::Conflict, /not ready/) end + it 'rejects assignment when entitlement revocation commits after its permission snapshot', isolation: :truncation do + app_guid = app.guid + account_guid = account.guid + writer = create(:user) + space.organization.add_user(writer) + space.add_developer(writer) + permission_queryer = Permissions.new(writer) + permission_checked = Queue.new + allow(permission_queryer).to receive(:can_write_to_active_space?).and_wrap_original do |method, *args| + result = method.call(*args) + permission_checked << true + result + end + + assignment = nil + account.db.transaction do + account.lock! + assignment = Thread.new do + AppAssignServiceAccount.new(permission_queryer).assign(AppModel.first(guid: app_guid), ServiceAccountModel.first(guid: account_guid)) + rescue AppAssignServiceAccount::Conflict => e + e + end + Timeout.timeout(10) { permission_checked.pop } + account.remove_entitled_space(space) + end + + result = Timeout.timeout(10) { assignment.value } + expect(result).to be_a(AppAssignServiceAccount::Conflict) + expect(result.message).to include('entitled') + expect(app.reload.service_account).to be_nil + expect(account.reload.entitled_spaces).to be_empty + ensure + assignment&.kill if assignment&.alive? + assignment&.join + 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) From 436df9dca6856ec495744116e9e8bae754a3dd29 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 14:25:15 +0100 Subject: [PATCH 164/200] Read current space entitlement after locking account for assignment --- app/actions/app_assign_service_account.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/actions/app_assign_service_account.rb b/app/actions/app_assign_service_account.rb index 8e87ccd4e87..7f5d1e2b913 100644 --- a/app/actions/app_assign_service_account.rb +++ b/app/actions/app_assign_service_account.rb @@ -22,7 +22,7 @@ def assign(app, account, provision: false) if account account.lock! raise Conflict.new('organization service account prefix is unset') unless account.organization.service_account_prefix - raise Conflict.new('account must be entitled to the app space in the same organization') unless account.entitled_to?(app.space) + raise Conflict.new('account must be entitled to the app space in the same organization') unless currently_entitled?(account, app.space) 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 @@ -41,6 +41,11 @@ def assign(app, account, provision: false) private + def currently_entitled?(account, space) + account.organization_id == space.organization_id && account.db[:service_account_space_entitlements]. + where(service_account_guid: account.guid, space_guid: space.guid).for_update.first + end + def validate_binding_change!(app, account) return unless app.service_account_guid && app.service_account_guid != account&.guid && app.service_bindings_dataset.exclude(service_account_guid: nil).any? From f285eccd97449ec71abc6ea0459b199cadd4ba22 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 16:16:39 +0100 Subject: [PATCH 165/200] Test independent CAPI user lifecycle for service accounts (red) --- spec/request/app_service_accounts_spec.rb | 2 +- spec/request/service_account_roles_spec.rb | 1 + spec/request/service_accounts_spec.rb | 2 +- .../service_account_org_lifecycle_spec.rb | 2 +- .../actions/service_account_provision_spec.rb | 99 +++++++++++++++++-- spec/unit/actions/space_delete_spec.rb | 2 +- .../jobs/v3/service_account_provision_spec.rb | 2 +- 7 files changed, 98 insertions(+), 12 deletions(-) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index 8a374b9ba5d..d6d02251ef2 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -103,7 +103,7 @@ def headers(role) 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 + expect(VCAP::CloudController::User.first(guid: account.client_id)).to be_nil end it 'reuses the active job for repeated binding and another app sharing the account' do diff --git a/spec/request/service_account_roles_spec.rb b/spec/request/service_account_roles_spec.rb index e8a6f1a8ccb..7d930e2770f 100644 --- a/spec/request/service_account_roles_spec.rb +++ b/spec/request/service_account_roles_spec.rb @@ -11,6 +11,7 @@ allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) allow(clients).to receive(:add) VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity').provision(account) + create(:user, guid: account.client_id, is_oauth_client: true) 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) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index b5e5c74f655..ced9d950faa 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -263,7 +263,7 @@ def headers(role) 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) + principal = create(:user, guid: account.client_id, is_oauth_client: true) org.add_user(principal) space.add_developer(principal) registration = provisioner.send(:registration, account) diff --git a/spec/unit/actions/service_account_org_lifecycle_spec.rb b/spec/unit/actions/service_account_org_lifecycle_spec.rb index 2bdda06d7bb..7baf3b65f8b 100644 --- a/spec/unit/actions/service_account_org_lifecycle_spec.rb +++ b/spec/unit/actions/service_account_org_lifecycle_spec.rb @@ -22,7 +22,7 @@ module VCAP::CloudController it 'keeps the enabled account, principal and grants when its last space is deleted' do provisioner.provision(account) - principal = User.first(guid: account.client_id) + principal = create(:user, guid: account.client_id, is_oauth_client: true) org.add_user(principal) app = create(:app_model, space: space, service_account: account) diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index dd940e216f0..bcff96ed633 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -27,15 +27,12 @@ module VCAP::CloudController } end - it 'creates one canonical client and a roleless OAuth principal before marking ready' do + it 'creates one canonical client without creating a CAPI user 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(User.first(guid: account.client_id)).to be_nil expect(account.reload.status).to eq('ready') end @@ -44,7 +41,7 @@ module VCAP::CloudController expect(clients).not_to receive(:add) action.provision(account) action.provision(account) - expect(User.where(guid: account.client_id).count).to eq(1) + expect(User.where(guid: account.client_id).count).to eq(0) end it 'accepts UAA normalized inert scope during disable reconciliation' do @@ -87,6 +84,94 @@ module VCAP::CloudController expect(account.reload.status).to eq('failed') end + it 'leaves independently managed CAPI users and their roles untouched during provisioning' do + principal = create(:user, guid: account.client_id, is_oauth_client: true) + space = create(:space) + space.organization.add_user(principal) + space.add_developer(principal) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + + action.provision(account) + + expect(principal.reload.spaces).to include(space) + expect(account.reload.status).to eq('ready') + end + + it 'does not delete an existing OAuth principal when deleting a never-provisioned account' do + principal = create(:user, guid: account.client_id, is_oauth_client: true) + space = create(:space) + space.organization.add_user(principal) + space.add_developer(principal) + account.update(enabled: false) + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + + action.deprovision(account, delete: true) + + expect(ServiceAccountModel.first(guid: account.guid)).to be_nil + expect(principal.reload.spaces).to include(space) + end + + it 'does not create or modify a CAPI principal when re-enabling a previously provisioned account' do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + action.provision(account) + principal = create(:user, guid: account.client_id, is_oauth_client: true, active: true) + space = create(:space) + space.organization.add_user(principal) + space.add_developer(principal) + account.update(enabled: false) + action.deprovision(account) + expect(principal.reload.active).to be(true) + expect(principal.spaces).to include(space) + principal.destroy + account.update(enabled: true) + + action.provision(account) + + expect(User.first(guid: account.client_id)).to be_nil + expect(account.reload.status).to eq('ready') + end + + it 'retains the independently managed CAPI user and its roles on account deletion' do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + action.provision(account) + principal = create(:user, guid: account.client_id, is_oauth_client: true) + space = create(:space) + space.organization.add_user(principal) + space.add_developer(principal) + account.update(enabled: false) + + action.deprovision(account, delete: true) + + expect(User.first(guid: principal.guid).id).to eq(principal.id) + expect(principal.reload.spaces).to include(space) + end + + it 'preserves explicit roles through failed re-enable and retry' do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + action.provision(account) + principal = create(:user, guid: account.client_id, is_oauth_client: true) + space = create(:space) + space.organization.add_user(principal) + space.add_developer(principal) + account.update(enabled: false) + action.deprovision(account) + account.update(enabled: true) + allow(clients).to receive(:add).and_raise('UAA unavailable') + + expect { action.provision(account) }.to raise_error('UAA unavailable') + expect(principal.reload.spaces).to include(space) + allow(clients).to receive(:add) + action.provision(account) + + expect(User.first(guid: account.client_id).id).to eq(principal.id) + expect(principal.reload.spaces).to include(space) + expect(account.reload.status).to eq('ready') + end + it 'does not provision disabled accounts' do account.update(enabled: false) expect(clients).not_to receive(:get) @@ -153,7 +238,7 @@ module VCAP::CloudController action.provision(account) expect(account.reload.status).to eq('ready') - expect(User.where(guid: account.client_id).count).to eq(1) + expect(User.where(guid: account.client_id).count).to eq(0) expect(WebMock::API.a_request(:post, "#{uaa_url}/oauth/clients")).not_to have_been_made end diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index 16776a0570b..3ab7306b629 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -42,7 +42,7 @@ module VCAP::CloudController it 'deletes assigned workloads while retaining the account and principal' do provisioner.provision(account) - principal = User.first(guid: account.client_id) + principal = create(:user, guid: account.client_id, is_oauth_client: true) space.organization.add_user(principal) space.add_auditor(principal) app.update(service_account: account) diff --git a/spec/unit/jobs/v3/service_account_provision_spec.rb b/spec/unit/jobs/v3/service_account_provision_spec.rb index d6cbe29da2c..36126824e85 100644 --- a/spec/unit/jobs/v3/service_account_provision_spec.rb +++ b/spec/unit/jobs/v3/service_account_provision_spec.rb @@ -25,7 +25,7 @@ module VCAP::CloudController restored.perform expect(account.reload.status).to eq('ready') - expect(User.first(guid: account.client_id).is_oauth_client).to be(true) + expect(User.first(guid: account.client_id)).to be_nil 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') From 4eaaf6dfab69e29522063141e086e32909e99793 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 16:16:51 +0100 Subject: [PATCH 166/200] Decouple CAPI users and roles from service account lifecycle --- app/actions/service_account_provision.rb | 5 ----- .../includes/api_resources/_service_accounts.md | 17 +++++++++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/actions/service_account_provision.rb b/app/actions/service_account_provision.rb index c37855ed487..bb2ccc34001 100644 --- a/app/actions/service_account_provision.rb +++ b/app/actions/service_account_provision.rb @@ -31,7 +31,6 @@ def provision(account) @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 @@ -53,9 +52,6 @@ def deprovision(account, delete: false) 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)) @@ -63,7 +59,6 @@ def deprovision(account, delete: false) @clients.delete(:client, account.client_id) end if delete - principal&.destroy account.destroy else account.update(status: 'disabled') diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md index 28f3392091a..a6c4cdf0567 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -81,16 +81,25 @@ staging has no account identity. Independent instance credentials remain intact. The managed client is secretless, client_credentials-only, bearer policy explicitly false for certificate binding, TTL 300, named CA bundle reference, exact SAN and required owning-org claim. CN/OU mappings provide instance/app/space/org attribution. -The principal starts roleless; explicit CAPI roles plus OAuth authorities authorize -CAPI access. Any registration difference is a collision, not adopted/reconciled. +Binding does not create a CAPI user. CAPI users and their explicit resource roles +are independently managed; the existing CAPI authentication flow can lazily create +a roleless OAuth user on its first API request. OAuth authorities alone do not +grant resource access. Any registration difference is a collision, not adopted/reconciled. Disable removes the managed registration asynchronously, stopping new token issuance when effective. Existing JWTs, certificate routes and external sessions remain valid until their own expiry/revocation. Enable recreates the registration and retains resource roles. Delete requires no desired/active/binding references. Space deletion removes workloads/entitlement but retains the org account and -authentication. Org deletion cleans workloads, clients, principals/grants and -accounts retryably; failed cleanup retains a deleting org. Names/prefixes persist. +authentication. Org deletion cleans workloads, managed UAA clients and accounts +retryably; failed cleanup retains a deleting org. Service-account deletion does +not delete independently managed CAPI users or their roles; ordinary space/org +deletion still removes roles on the deleted resources. Names/prefixes persist. + +This intentionally diverges from RFC Appendix D's automatic roleless-principal +creation on first bind: service-account provisioning and retirement manage UAA +clients, not CAPI user lifecycles. Existing users/roles can therefore apply to the +same client ID after explicit identity reuse; remove those grants independently. ## Broker and routes From 25923eed9b80af001eb41b07422f04dc07ffc6a7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 16:27:36 +0100 Subject: [PATCH 167/200] Test org cleanup of unprovisioned account reservations (red) --- spec/unit/actions/organization_delete_spec.rb | 1 + .../service_account_org_lifecycle_spec.rb | 39 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/spec/unit/actions/organization_delete_spec.rb b/spec/unit/actions/organization_delete_spec.rb index 6c0e0d5dad9..2f3b2c29fe7 100644 --- a/spec/unit/actions/organization_delete_spec.rb +++ b/spec/unit/actions/organization_delete_spec.rb @@ -94,6 +94,7 @@ module VCAP::CloudController end it 'keeps the organization until account cleanup succeeds' do + account.update(status: 'ready') allow(clients).to receive(:get).and_raise('UAA unavailable') errors = org_delete.delete(org_dataset) expect(errors.map(&:message).join).to include('UAA unavailable') diff --git a/spec/unit/actions/service_account_org_lifecycle_spec.rb b/spec/unit/actions/service_account_org_lifecycle_spec.rb index 7baf3b65f8b..dcf658d8ce1 100644 --- a/spec/unit/actions/service_account_org_lifecycle_spec.rb +++ b/spec/unit/actions/service_account_org_lifecycle_spec.rb @@ -40,7 +40,7 @@ module VCAP::CloudController end it 'cleans up a zero-entitlement account on org deletion, retaining the parent after failure for retry' do - account + provisioner.provision(account) expect(space_delete.delete([space])).to be_empty allow(clients).to receive(:get).and_raise('UAA unavailable') @@ -55,6 +55,43 @@ module VCAP::CloudController expect(Organization.first(id: org.id)).to be_nil end + it 'deletes an unused reservation without provisioning configuration and retains its deletion history' do + guid = account.guid + org_guid = org.guid + locator = CloudController::DependencyLocator.instance + expect(locator).not_to receive(:service_account_provisioner) + + expect(org_delete.delete([org])).to be_empty + + expect(ServiceAccountModel.first(guid: guid)).to be_nil + expect(Organization.first(id: org.id)).to be_nil + expect(ServiceAccountModel.db[:service_account_names].where(name: account.name, organization_id: org.id).first[:deleted_at]).not_to be_nil + expect(ServiceAccountModel.db[:service_account_name_deletions].where(account_guid: guid, organization_guid: org_guid)).not_to be_empty + expect(Event.where(type: 'audit.service_account.delete', actee: guid)).not_to be_empty + expect(clients).not_to have_received(:get) + end + + it 'retains a failed account and its org when provisioning configuration is unavailable' do + account.update(status: 'failed') + allow(CloudController::DependencyLocator.instance).to receive(:service_account_provisioner).and_raise('service account provisioning is not configured') + + expect(org_delete.delete([org]).map(&:message).join).to include('service account provisioning is not configured') + + expect(ServiceAccountModel.first(guid: account.guid)).not_to be_nil + expect(org.reload.status).to eq(Organization::DELETING) + end + + it 'does not locally delete a reservation with an active lifecycle job' do + job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(Jobs::V3::ServiceAccountProvision.new(account.guid)) + expect(CloudController::DependencyLocator.instance).not_to receive(:service_account_provisioner) + + expect(org_delete.delete([org]).map(&:message).join).to include('operation is already in progress') + + expect(account.reload.status).to eq('reserved') + expect(PollableJobModel.first(guid: job.guid)).not_to be_nil + expect(org.reload.status).to eq(Organization::DELETING) + end + it 'does not bypass shared-domain deletion guards when accounts mark an org deleting' do account domain = create(:private_domain, owning_organization: org) From f9f707f53a132d2bb7b9f4242454fb2eeb1e8571 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 11 Nov 2026 16:27:48 +0100 Subject: [PATCH 168/200] Clean unused account reservations without UAA provisioning dependency --- app/actions/organization_delete.rb | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/actions/organization_delete.rb b/app/actions/organization_delete.rb index abf81502eed..0df3c0c7e77 100644 --- a/app/actions/organization_delete.rb +++ b/app/actions/organization_delete.rb @@ -76,18 +76,26 @@ def delete_service_accounts(org) accounts = ServiceAccountModel.where(organization_id: org.id).order(:guid).all return [] if accounts.empty? - provisioner = CloudController::DependencyLocator.instance.service_account_provisioner accounts.each_with_object([]) do |account, errors| + locally_deleted = false account.db.transaction do 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') + if account.status == 'reserved' && !account.in_use? + Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) + account.destroy + locally_deleted = true + else + account.update(enabled: false, status: 'deleting') + end end + next if locally_deleted + Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) - provisioner.deprovision(account, delete: true) + CloudController::DependencyLocator.instance.service_account_provisioner.deprovision(account, delete: true) rescue StandardError => e errors << e end From a21dd5e8c4f54d62c2d0f07c22db046fcb77d8f0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 10:07:27 +0100 Subject: [PATCH 169/200] Remove unpublished POC compatibility and reservation statistics --- app/actions/space_delete.rb | 1 - app/models/runtime/service_account_model.rb | 40 +++++---------- .../v3/service_account_presenter.rb | 3 +- .../20261005120000_create_service_accounts.rb | 20 +++++--- ...ate_service_account_space_entitlements.rb} | 19 ------- ...0_allow_accounts_without_original_space.rb | 11 ---- ..._service_account_names_to_organizations.rb | 50 ------------------- ...create_service_account_deletion_history.rb | 2 +- ...30000_preserve_legacy_account_namespace.rb | 16 ------ .../api_resources/_service_accounts.md | 21 ++------ .../diego/service_account_identity.rb | 4 +- ...ice_account_organization_ownership_spec.rb | 28 ----------- ...service_account_namespace_rollback_spec.rb | 25 ---------- .../migrations/service_account_schema_spec.rb | 16 ++++++ spec/request/app_service_accounts_spec.rb | 2 +- .../service_account_entitlements_spec.rb | 17 ------- spec/request/service_accounts_spec.rb | 10 ++-- spec/support/factory_definitions/space.rb | 4 ++ .../app_assign_service_account_spec.rb | 4 +- spec/unit/actions/organization_delete_spec.rb | 5 +- spec/unit/actions/process_restart_spec.rb | 2 + .../service_account_binding_guard_spec.rb | 6 +-- .../service_account_creation_budget_spec.rb | 2 +- .../service_account_org_lifecycle_spec.rb | 3 +- .../actions/service_account_provision_spec.rb | 2 +- spec/unit/actions/space_delete_spec.rb | 5 +- spec/unit/actions/task_create_spec.rb | 2 + .../jobs/v3/service_account_provision_spec.rb | 2 +- .../staging_environment_builder_spec.rb | 1 + .../diego/app_recipe_builder_spec.rb | 3 +- .../diego/environment_spec.rb | 4 +- .../diego/service_account_identity_spec.rb | 5 +- .../diego/task_environment_spec.rb | 4 +- .../diego/task_recipe_builder_spec.rb | 2 + ..._account_provisioner_configuration_spec.rb | 2 +- .../service_account_assignment_spec.rb | 7 +-- .../runtime/service_account_model_spec.rb | 24 +++++---- .../runtime/service_account_ownership_spec.rb | 21 ++------ 38 files changed, 117 insertions(+), 278 deletions(-) rename db/migrations/{20261109121000_add_service_account_organization_ownership.rb => 20261109121000_create_service_account_space_entitlements.rb} (51%) delete mode 100644 db/migrations/20261109122000_allow_accounts_without_original_space.rb delete mode 100644 db/migrations/20261109123000_scope_service_account_names_to_organizations.rb delete mode 100644 db/migrations/20261109130000_preserve_legacy_account_namespace.rb delete mode 100644 spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb delete mode 100644 spec/migrations/service_account_namespace_rollback_spec.rb create mode 100644 spec/migrations/service_account_schema_spec.rb diff --git a/app/actions/space_delete.rb b/app/actions/space_delete.rb index f11835e8310..927d8654d58 100644 --- a/app/actions/space_delete.rb +++ b/app/actions/space_delete.rb @@ -27,7 +27,6 @@ def delete(dataset) Space.db.transaction do delete_apps(space_model) - ServiceAccountModel.where(space_guid: space_model.guid).update(space_guid: nil) space_model.destroy Repositories::SpaceEventRepository.new.record_space_delete_request(space_model, @user_audit_info, true) end diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index f5688364ab7..50800aea160 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -2,25 +2,9 @@ module VCAP::CloudController class ServiceAccountModel < Sequel::Model(:service_accounts) NAME_PATTERN = /\A[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\z/ - def self.reservation_statistics(now: Time.now.utc) - reservations = db[:service_account_names] - history = db[:service_account_name_deletions] - ages = { '0-7' => 0, '8-30' => 0, '31-90' => 0, '91+' => 0 } - [7, 30, 90].each_with_index do |days, index| - younger_than = now - (days * 86_400) - older_than = index.zero? ? now : now - ([7, 30][index - 1] * 86_400) - ages[ages.keys[index]] = history.where { deleted_at >= younger_than }.where { deleted_at <= older_than }.count - end - ages['91+'] = history.where { deleted_at < now - (90 * 86_400) }.count - { reserved_names: reservations.count, deletions: history.count, - unknown_deletion_times: reservations.where(deleted_at: nil).where(Sequel.~(dataset.where( - Sequel[:service_accounts][:name] => Sequel[:service_account_names][:name], - Sequel[:service_accounts][:organization_id] => Sequel[:service_account_names][:organization_id] - ).exists)).count, - deletion_age_days: ages } - end + # Creation input for the initial entitlement; never persisted as ownership. + attr_accessor :space - many_to_one :space, key: :space_guid, primary_key: :guid, without_guid_generation: true many_to_one :organization many_to_many :entitled_spaces, class: 'VCAP::CloudController::Space', @@ -43,22 +27,29 @@ def validate super validates_presence :space if new? validates_presence :organization + validate_identity_prefix errors.add(:organization, 'must own the initial space') if new? && space && organization_id != space.organization_id errors.add(:organization, 'is immutable') if !new? && column_changed?(:organization_id) validate_identity_immutability 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) && space_guid end def validate_identity_immutability errors.add(:identity_prefix, 'is immutable') if !new? && column_changed?(:identity_prefix) end + def validate_identity_prefix + validates_presence :identity_prefix + return unless organization + + errors.add(:organization, 'service account prefix is unset') unless organization.service_account_prefix + errors.add(:identity_prefix, 'must match the owning organization prefix') if new? && identity_prefix != organization.service_account_prefix + end + def around_create db.transaction(savepoint: true) do organization.lock! - reserve_legacy_namespace! unless identity_prefix quota = organization.quota_definition limit = quota.total_service_accounts if limit != -1 && self.class.where(organization_id: organization_id).count >= limit @@ -75,18 +66,13 @@ def around_create raise validation_failed_error end - def reserve_legacy_namespace! - # The pre-prefix namespace remains globally reserved throughout retirement. - db[:legacy_service_account_names].insert(name: name) # rubocop:disable Rails/SkipsModelValidations -- Atomic Sequel namespace reservation. - end - def after_create super add_entitled_space(space) end def client_id - identity_prefix ? "cf:service-account:#{identity_prefix}:#{name}" : "cf:service-account:#{name}" + "cf:service-account:#{identity_prefix}:#{name}" end def in_use? @@ -114,7 +100,7 @@ def validate_entitled_space!(space) end def certificate_dns_san - identity_prefix ? "#{name}.#{identity_prefix}.svc.identity" : "#{name}.svc.identity" + "#{name}.#{identity_prefix}.svc.identity" end def before_destroy diff --git a/app/presenters/v3/service_account_presenter.rb b/app/presenters/v3/service_account_presenter.rb index 6ce36d05405..d31711fd7c0 100644 --- a/app/presenters/v3/service_account_presenter.rb +++ b/app/presenters/v3/service_account_presenter.rb @@ -20,8 +20,7 @@ def to_hash enabled: @resource.enabled, status: @resource.status, relationships: { organization: { data: { guid: @resource.organization.guid } }, - spaces: { data: @resource.entitled_spaces.map { |space| { guid: space.guid } } }, - space: { data: @resource.space_guid ? { guid: @resource.space_guid } : nil } }, + spaces: { data: @resource.entitled_spaces.map { |space| { guid: space.guid } } } }, links: { self: { href: url_builder.build_url(path: "/v3/service_accounts/#{@resource.guid}") } } } end diff --git a/db/migrations/20261005120000_create_service_accounts.rb b/db/migrations/20261005120000_create_service_accounts.rb index 44bd93543d8..82b221520e6 100644 --- a/db/migrations/20261005120000_create_service_accounts.rb +++ b/db/migrations/20261005120000_create_service_accounts.rb @@ -1,20 +1,28 @@ Sequel.migration do - change do + up do + organization_id_type = schema(:organizations).to_h.fetch(:id).fetch(:db_type) 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 + column :organization_id, organization_id_type, null: false + DateTime :deleted_at + index %i[organization_id name], unique: true, name: :service_account_names_org_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 + column :organization_id, organization_id_type, null: false + foreign_key [:organization_id], :organizations, name: :fk_service_accounts_organization + String :identity_prefix, size: 63, null: false + index %i[organization_id name], unique: true, name: :service_accounts_org_name_unique TrueClass :enabled, null: false, default: true String :status, size: 32, null: false, default: 'reserved' end end + + down do + drop_table :service_accounts + drop_table :service_account_names + end end diff --git a/db/migrations/20261109121000_add_service_account_organization_ownership.rb b/db/migrations/20261109121000_create_service_account_space_entitlements.rb similarity index 51% rename from db/migrations/20261109121000_add_service_account_organization_ownership.rb rename to db/migrations/20261109121000_create_service_account_space_entitlements.rb index 9201ea0b1ea..2f567e8684c 100644 --- a/db/migrations/20261109121000_add_service_account_organization_ownership.rb +++ b/db/migrations/20261109121000_create_service_account_space_entitlements.rb @@ -1,11 +1,5 @@ Sequel.migration do up do - organization_id_type = schema(:organizations).to_h.fetch(:id).fetch(:db_type) - alter_table :service_accounts do - add_column :organization_id, organization_id_type - add_foreign_key [:organization_id], :organizations, name: :fk_service_accounts_organization - end - account_guid_options = {} space_guid_options = {} if database_type == :mysql @@ -22,22 +16,9 @@ foreign_key [:service_account_guid], :service_accounts, key: :guid, name: :fk_sa_entitlements_account, on_delete: :cascade foreign_key [:space_guid], :spaces, key: :guid, name: :fk_sa_entitlements_space, on_delete: :cascade end - - # Preserve UUIDs, names, authentication state and launch references. The - # original space is retained for old readers until the entitlement cutover. - from(:service_accounts).each do |account| - organization_id = from(:spaces).where(guid: account[:space_guid]).get(:organization_id) - from(:service_accounts).where(id: account[:id]).update(organization_id: organization_id) - from(:service_account_space_entitlements).insert(service_account_guid: account[:guid], space_guid: account[:space_guid]) - end - alter_table(:service_accounts) { set_column_not_null :organization_id } end down do drop_table :service_account_space_entitlements - alter_table :service_accounts do - drop_foreign_key [:organization_id], name: :fk_service_accounts_organization - drop_column :organization_id - end end end diff --git a/db/migrations/20261109122000_allow_accounts_without_original_space.rb b/db/migrations/20261109122000_allow_accounts_without_original_space.rb deleted file mode 100644 index 75aa19e3010..00000000000 --- a/db/migrations/20261109122000_allow_accounts_without_original_space.rb +++ /dev/null @@ -1,11 +0,0 @@ -Sequel.migration do - up do - alter_table(:service_accounts) { set_column_allow_null :space_guid } - end - - down do - raise Sequel::Error.new('Cannot restore space ownership while accounts have no original space') if from(:service_accounts).where(space_guid: nil).any? - - alter_table(:service_accounts) { set_column_not_null :space_guid } - end -end diff --git a/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb b/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb deleted file mode 100644 index 61ce9fd64de..00000000000 --- a/db/migrations/20261109123000_scope_service_account_names_to_organizations.rb +++ /dev/null @@ -1,50 +0,0 @@ -Sequel.migration do - up do - organization_id_type = schema(:organizations).to_h.fetch(:id).fetch(:db_type) - alter_table :service_accounts do - drop_foreign_key [:name], name: :fk_service_accounts_name - drop_index :name, name: :service_accounts_name_unique, concurrently: false - add_column :identity_prefix, String, size: 63 - end - alter_table :service_account_names do - add_column :organization_id, organization_id_type - add_column :deleted_at, DateTime - drop_index :name, name: :service_account_names_name_unique, concurrently: false - add_index %i[organization_id name], unique: true, name: :service_account_names_org_name_unique, concurrently: false - end - from(:service_accounts).each do |account| - from(:service_account_names).where(name: account[:name]).update(organization_id: account[:organization_id]) - end - # Existing unqualified accounts and orphan historical reservations are retained. - # Do not infer a deleted org or historical deletion time from absent evidence. - alter_table :service_accounts do - add_index %i[organization_id name], unique: true, name: :service_accounts_org_name_unique, concurrently: false - end - end - - down do - raise Sequel::Error.new('Org-local identities cannot be downgraded to the global account namespace') if from(:service_accounts).any? - - duplicate_names = from(:service_account_names).group(:name).having { count.function.* > 1 }.any? - raise Sequel::Error.new('Org-local reservations cannot be collapsed into a global namespace') if duplicate_names - - # InnoDB may discard its original FK index when the composite index is added. - # Restore a supporting index before removing the composite one on rollback. - if database_type == :mysql && indexes(:service_accounts).values.none? { |index| index[:columns] == [:organization_id] } - alter_table(:service_accounts) { add_index :organization_id, name: :fk_service_accounts_organization, concurrently: false } - end - - alter_table :service_accounts do - drop_index %i[organization_id name], name: :service_accounts_org_name_unique, concurrently: false - drop_column :identity_prefix - add_index :name, unique: true, name: :service_accounts_name_unique, concurrently: false - end - alter_table :service_account_names do - drop_index %i[organization_id name], name: :service_account_names_org_name_unique, concurrently: false - drop_column :organization_id - drop_column :deleted_at - add_index :name, unique: true, name: :service_account_names_name_unique, concurrently: false - end - alter_table(:service_accounts) { add_foreign_key [:name], :service_account_names, key: :name, name: :fk_service_accounts_name } - end -end diff --git a/db/migrations/20261109125000_create_service_account_deletion_history.rb b/db/migrations/20261109125000_create_service_account_deletion_history.rb index 2f9dfe86ead..9567faf72d8 100644 --- a/db/migrations/20261109125000_create_service_account_deletion_history.rb +++ b/db/migrations/20261109125000_create_service_account_deletion_history.rb @@ -5,7 +5,7 @@ String :account_guid, size: 255, null: false String :organization_guid, size: 255, null: false String :name, size: 63, null: false - String :identity_prefix, size: 63 + String :identity_prefix, size: 63, null: false DateTime :deleted_at, null: false index :account_guid, unique: true, name: :service_account_deletions_account_unique index :deleted_at, name: :service_account_deletions_time_index diff --git a/db/migrations/20261109130000_preserve_legacy_account_namespace.rb b/db/migrations/20261109130000_preserve_legacy_account_namespace.rb deleted file mode 100644 index ec4c06aac6d..00000000000 --- a/db/migrations/20261109130000_preserve_legacy_account_namespace.rb +++ /dev/null @@ -1,16 +0,0 @@ -Sequel.migration do - up do - create_table :legacy_service_account_names do - primary_key :id, name: :legacy_service_account_names_pkey - String :name, size: 63, null: false - index :name, unique: true, name: :legacy_service_account_names_unique - end - from(:service_account_names).select(:name).distinct.each do |reservation| - from(:legacy_service_account_names).insert(name: reservation[:name]) - end - end - - down do - drop_table :legacy_service_account_names - 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 a6c4cdf0567..d40f223de1a 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -40,8 +40,7 @@ Creation uses the target space to choose the org and grant the initial entitleme Optional fields: `description`, `metadata`, `reuse_name`. Identity/state and owning org are platform controlled. Account representations expose `organization` and -`spaces` relationships. The nullable legacy `space` relationship is retained for -transitional clients; use `organization`/`spaces` for new clients. +`spaces` relationships. | Method | Path | Behavior | | --- | --- | --- | @@ -128,20 +127,10 @@ BOSH `cc.service_accounts` properties: - Worker-only optional `allowed_resources_ref`: requires UAA named-resource-policy support (#4125). Leave unset until supported; CAPI does not copy audience lists. -Existing POC accounts backfill org/initial entitlement without changing UUID, -name, client ID or SAN. Immutable `identity_prefix` snapshots distinguish new -qualified accounts from legacy accounts; allocating an org prefix does not rewrite -old clients. Global legacy reservations protect old unqualified subjects. Retire -legacy identities deliberately, removing external grants and allowing certificate -expiry. Existing client registrations need deliberate profile migration/retirement; -strict ownership checking does not overwrite old registration profiles. - -Coordinate writers during schema migration: old writers cannot supply required org -ownership. Downgrade refuses populated org-local identities or duplicate org-local -reservations rather than losing isolation. Retain backups and pinned releases. -`ServiceAccountModel.reservation_statistics` provides counts, age buckets and unknown -historical dates for redacted operational collection; compare timestamped samples -for growth. No automatic reservation release is implemented. +Accounts use only prefix-qualified identities and org ownership. The schema does +not include migration paths for earlier unpublished proposal models. Deletion +timestamps are retained for future operational queries; no statistics collection +or automatic reservation release is implemented. Remaining external acceptance: qualified Diego/router rollout, UAA resource-policy support, min(300s, remaining certificate lifetime) issuance, management namespace diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb index 67ac881df50..9106ac215a8 100644 --- a/lib/cloud_controller/diego/service_account_identity.rb +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -12,7 +12,7 @@ def initialize(app, config, account_guid: app.service_account_guid) 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, prefix: account.identity_prefix.to_s) if account + attributes[:service_account] = ::Diego::Bbs::Models::ServiceAccount.new(name: account.name, prefix: account.identity_prefix) if account ::Diego::Bbs::Models::CertificateProperties.new(attributes) end @@ -48,7 +48,7 @@ def ready_account end account = ServiceAccountModel.first(guid: @account_guid) - if account&.identity_prefix && @config.get(:service_account_runtime_contract_version) != 2 + if @config.get(:service_account_runtime_contract_version) != 2 raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account qualified runtime contract version 2 is not enabled') end unless account && account.enabled && account.status == 'ready' && account.entitled_to?(@app.space) diff --git a/spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb b/spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb deleted file mode 100644 index cc8c7934441..00000000000 --- a/spec/migrations/20261109121000_add_service_account_organization_ownership_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'spec_helper' -require 'migrations/helpers/migration_shared_context' - -RSpec.describe 'Service account organization ownership migration', isolation: :truncation, type: :migration do - include_context 'migration' do - let(:migration_filename) { '20261109121000_add_service_account_organization_ownership.rb' } - end - - it 'backfills the owning org and initial entitlement without changing legacy identities or assigning prefixes' do - space = create(:space) - guid = SecureRandom.uuid - now = Time.now.utc - db[:service_account_names].insert(name: 'legacy-worker') - db[:service_accounts].insert(guid: guid, name: 'legacy-worker', space_guid: space.guid, created_at: now, updated_at: now) - - Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) - - account = db[:service_accounts].first(guid: guid) - expect(account).to include(organization_id: space.organization_id, name: 'legacy-worker', space_guid: space.guid) - expect(db[:service_account_space_entitlements].where(service_account_guid: guid, space_guid: space.guid).count).to eq(1) - expect(db[:organizations].first(id: space.organization_id)[:service_account_prefix]).to be_nil - expect(db[:service_account_names].where(name: 'legacy-worker').count).to eq(1) - - Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) - expect(db[:service_accounts].first(guid: guid)).to include(name: 'legacy-worker', space_guid: space.guid) - expect(db.table_exists?(:service_account_space_entitlements)).to be(false) - end -end diff --git a/spec/migrations/service_account_namespace_rollback_spec.rb b/spec/migrations/service_account_namespace_rollback_spec.rb deleted file mode 100644 index 8223bc8431e..00000000000 --- a/spec/migrations/service_account_namespace_rollback_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -require 'spec_helper' - -RSpec.describe 'Service account namespace rollback', isolation: :truncation do - it 'allows reverting an empty transition after MySQL has selected the composite index for the org foreign key' do - db = Sequel::Model.db - skip 'MySQL chooses foreign-key supporting indexes' unless db.database_type == :mysql - - db.alter_table(:service_accounts) do - drop_foreign_key [:organization_id], name: :fk_service_accounts_organization - drop_index :organization_id, name: :fk_service_accounts_organization, concurrently: false if db.indexes(:service_accounts).key?(:fk_service_accounts_organization) - add_foreign_key [:organization_id], :organizations, name: :fk_service_accounts_organization - end - - expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_109_122_999) }.not_to raise_error - Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) - end - - it 'allows reverting empty account assignments and restoring their foreign key' do - db = Sequel::Model.db - expect(db[:service_accounts].count).to eq(0) - expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_005_120_000) }.not_to raise_error - Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) - expect(db.foreign_key_list(:apps)).to include(hash_including(columns: [:service_account_guid], table: :service_accounts)) - end -end diff --git a/spec/migrations/service_account_schema_spec.rb b/spec/migrations/service_account_schema_spec.rb new file mode 100644 index 00000000000..adf725333d0 --- /dev/null +++ b/spec/migrations/service_account_schema_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +RSpec.describe 'Service account schema', isolation: :truncation do + it 'allows reverting empty account assignments and restoring their foreign key' do + db = Sequel::Model.db + expect(db[:service_accounts].count).to eq(0) + expect { Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS, target: 20_261_005_120_000) }.not_to raise_error + Sequel::Migrator.run(db, DBMigrator::SEQUEL_MIGRATIONS) + expect(db.foreign_key_list(:apps)).to include(hash_including(columns: [:service_account_guid], table: :service_accounts)) + columns = db.schema(:service_accounts).to_h + expect(columns).not_to have_key(:space_guid) + expect(columns.fetch(:organization_id)[:allow_null]).to be(false) + expect(columns.fetch(:identity_prefix)[:allow_null]).to be(false) + expect(db.schema(:service_account_names).to_h).to have_key(:deleted_at) + end +end diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index d6d02251ef2..f893847dfbe 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -146,7 +146,7 @@ def headers(role) 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)) + other = VCAP::CloudController::ServiceAccountModel.create(name: 'other-worker', space: create(:space, :with_service_account_prefix)) patch path, { data: { guid: other.guid } }.to_json, headers('admin') expect(last_response.status).to eq(409) expect(Delayed::Job.count).to eq(0) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index 1e10f011d33..c3c0c50364d 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -85,14 +85,6 @@ def entitle(destination, headers) expect(ServiceAccountModel.first(guid: parsed_response['guid']).entitled_spaces).to contain_exactly(space) end - it 'refuses entitlements for an org with no allocated prefix' do - legacy_space = create(:space) - legacy = ServiceAccountModel.create(name: 'legacy-worker', space: legacy_space) - destination = create(:space, organization: legacy_space.organization) - post "/v3/service_accounts/#{legacy.guid}/relationships/spaces", { data: [{ guid: destination.guid }] }.to_json, admin_headers_for(user) - expect(last_response.status).to eq(422) - end - it 'does not let a developer in a suspended entitled space manage the account' do headers = auth('space_developer') account @@ -101,15 +93,6 @@ def entitle(destination, headers) expect(last_response.status).to eq(403) end - it 'refuses a new assignment for a legacy account whose org prefix is unset' do - legacy_space = create(:space) - legacy = ServiceAccountModel.create(name: 'legacy-worker', space: legacy_space, status: 'ready') - app = create(:app_model, space: legacy_space) - patch "/v3/apps/#{app.guid}/relationships/service_account", { data: { guid: legacy.guid } }.to_json, admin_headers_for(user) - expect(last_response.status).to eq(409) - expect(app.reload.service_account_guid).to be_nil - end - it 'refuses adding an entitlement to a deleting account' do account.update(status: 'deleting', enabled: false) entitle(target, auth('org_manager')) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index ced9d950faa..a06ba400d17 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -17,8 +17,8 @@ def headers(role) result = Oj.load(last_response.body) expect(result).to include('name' => 'payments-worker', 'status' => 'reserved', 'enabled' => true, 'client_id' => 'cf:service-account:test-team:payments-worker', 'certificate_dns_san' => 'payments-worker.test-team.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) + expect(result.dig('relationships', 'organization', 'data', 'guid')).to eq(org.guid) + expect(result.dig('relationships', 'spaces', 'data')).to eq([{ 'guid' => 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) @@ -157,7 +157,7 @@ def headers(role) expect(last_response.status).to eq(403) end - it 'allows an owning-space member to read the account' do + it 'allows an entitled-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') @@ -203,7 +203,7 @@ def headers(role) 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)) + VCAP::CloudController::ServiceAccountModel.create(name: 'hidden-worker', space: create(:space, :with_service_account_prefix)) 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) @@ -227,7 +227,7 @@ def headers(role) 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(200) - hidden = VCAP::CloudController::ServiceAccountModel.create(name: 'hidden-worker', space: create(:space)) + hidden = VCAP::CloudController::ServiceAccountModel.create(name: 'hidden-worker', space: create(:space, :with_service_account_prefix)) patch "/v3/service_accounts/#{hidden.guid}", { description: 'unauthorized' }.to_json, headers('space_manager') expect(last_response.status).to eq(404) end diff --git a/spec/support/factory_definitions/space.rb b/spec/support/factory_definitions/space.rb index 8525f624c25..cf6bfcd43e1 100644 --- a/spec/support/factory_definitions/space.rb +++ b/spec/support/factory_definitions/space.rb @@ -2,5 +2,9 @@ factory :space, class: 'VCAP::CloudController::Space' do name { generate(:name) } association :organization + + trait :with_service_account_prefix do + organization { association(:organization, service_account_prefix: "workload-#{generate(:name)}") } + end end end diff --git a/spec/unit/actions/app_assign_service_account_spec.rb b/spec/unit/actions/app_assign_service_account_spec.rb index 401eba61ac0..44ad4910cf1 100644 --- a/spec/unit/actions/app_assign_service_account_spec.rb +++ b/spec/unit/actions/app_assign_service_account_spec.rb @@ -2,14 +2,14 @@ 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 + RSpec.describe 'Entitled-space service account assignment' do let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'assignment-team')) } 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 + it 'allows an app writer to use an account in an entitled space' do action.assign(app, account) expect(app.reload.service_account).to eq(account) expect(app.desired_state).to eq('STOPPED') diff --git a/spec/unit/actions/organization_delete_spec.rb b/spec/unit/actions/organization_delete_spec.rb index 2f3b2c29fe7..4f3a9829c80 100644 --- a/spec/unit/actions/organization_delete_spec.rb +++ b/spec/unit/actions/organization_delete_spec.rb @@ -74,7 +74,10 @@ module VCAP::CloudController describe 'recursive deletion' do context 'service accounts' do - let!(:account) { ServiceAccountModel.create(name: 'org-worker', space: space) } + let!(:account) do + space.organization.update(service_account_prefix: 'org-delete-team') + ServiceAccountModel.create(name: 'org-worker', space: space) + end let(:clients) { instance_double(CF::UAA::Scim) } let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } diff --git a/spec/unit/actions/process_restart_spec.rb b/spec/unit/actions/process_restart_spec.rb index dc22232bb7f..43fb3a7286b 100644 --- a/spec/unit/actions/process_restart_spec.rb +++ b/spec/unit/actions/process_restart_spec.rb @@ -50,6 +50,7 @@ module VCAP::CloudController context 'when the process is STARTED' do it 'does not grant a newly bound account to a pre-feature launch before restart' do + app.space.organization.update(service_account_prefix: 'process-team') 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) @@ -57,6 +58,7 @@ module VCAP::CloudController end it 'captures account identity only at restart and preserves it after delayed assignment changes' do + app.space.organization.update(service_account_prefix: 'process-team') 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) diff --git a/spec/unit/actions/service_account_binding_guard_spec.rb b/spec/unit/actions/service_account_binding_guard_spec.rb index 4fc4f4bc3fd..386221c873f 100644 --- a/spec/unit/actions/service_account_binding_guard_spec.rb +++ b/spec/unit/actions/service_account_binding_guard_spec.rb @@ -4,7 +4,7 @@ module VCAP::CloudController RSpec.describe 'Service account identity binding guard' do it 'rejects binding snapshots whose account entitlement was revoked before persistence' do - space = create(:space) + space = create(:space, :with_service_account_prefix) account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') app = create(:app_model, space: space, service_account: account) account.remove_all_entitled_spaces @@ -14,7 +14,7 @@ module VCAP::CloudController end it 'counts identity-bearing bindings as account lifecycle references' do - space = create(:space) + space = create(:space, :with_service_account_prefix) account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') app = create(:app_model, space: space, service_account: account) create(:service_binding, app: app, service_instance: create(:managed_service_instance, space: space)) @@ -35,7 +35,7 @@ module VCAP::CloudController end it 'refuses to unassign an account while a broker binding depends on the identity' do - space = create(:space) + space = create(:space, :with_service_account_prefix) account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') app = create(:app_model, space: space, service_account: account) create(:service_binding, app: app, service_instance: create(:managed_service_instance, space: space)) diff --git a/spec/unit/actions/service_account_creation_budget_spec.rb b/spec/unit/actions/service_account_creation_budget_spec.rb index 377aadf8d65..6e0b81230ef 100644 --- a/spec/unit/actions/service_account_creation_budget_spec.rb +++ b/spec/unit/actions/service_account_creation_budget_spec.rb @@ -6,7 +6,7 @@ module VCAP::CloudController 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) + spaces = create_list(:space, 2, :with_service_account_prefix) gate = Queue.new snapshots = Queue.new consume_budget = Queue.new diff --git a/spec/unit/actions/service_account_org_lifecycle_spec.rb b/spec/unit/actions/service_account_org_lifecycle_spec.rb index dcf658d8ce1..15a4e111511 100644 --- a/spec/unit/actions/service_account_org_lifecycle_spec.rb +++ b/spec/unit/actions/service_account_org_lifecycle_spec.rb @@ -3,7 +3,7 @@ module VCAP::CloudController RSpec.describe 'Org-owned service account lifecycle' do - let(:space) { create(:space) } + let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'lifecycle-team')) } let(:org) { space.organization } let(:account) { ServiceAccountModel.create(name: 'shared-worker', space: space) } let(:actor) { UserAuditInfo.new(user_guid: create(:user).guid, user_email: 'manager@example.com') } @@ -32,7 +32,6 @@ module VCAP::CloudController expect(account.enabled).to be(true) expect(account.status).to eq('ready') expect(account.entitled_spaces).to be_empty - expect(account.space_guid).to be_nil expect(User.first(guid: principal.guid)).not_to be_nil expect(org.users_dataset.where(users__id: principal.id).any?).to be(true) expect(AppModel.first(guid: app.guid)).to be_nil diff --git a/spec/unit/actions/service_account_provision_spec.rb b/spec/unit/actions/service_account_provision_spec.rb index bcff96ed633..82686a0b0ea 100644 --- a/spec/unit/actions/service_account_provision_spec.rb +++ b/spec/unit/actions/service_account_provision_spec.rb @@ -3,7 +3,7 @@ module VCAP::CloudController RSpec.describe 'Service account provisioning' do - let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space)) } + let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space, :with_service_account_prefix)) } let(:clients) { instance_double(CF::UAA::Scim) } let(:action) { VCAP::CloudController.const_get(:ServiceAccountProvision).new(clients, identity_ca_ref: 'diego-instance-identity') } let(:registration) do diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index 3ab7306b629..b8fd5cba448 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -28,7 +28,10 @@ module VCAP::CloudController end context 'owned service accounts' do - let!(:account) { ServiceAccountModel.create(name: 'payments-worker', space: space) } + let!(:account) do + space.organization.update(service_account_prefix: 'space-delete-team') + ServiceAccountModel.create(name: 'payments-worker', space: space) + end let(:clients) { instance_double(CF::UAA::Scim) } let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } diff --git a/spec/unit/actions/task_create_spec.rb b/spec/unit/actions/task_create_spec.rb index ef6aadf7798..cf1b19c2b2d 100644 --- a/spec/unit/actions/task_create_spec.rb +++ b/spec/unit/actions/task_create_spec.rb @@ -73,6 +73,7 @@ module VCAP::CloudController end it 'refuses a task when the account entitlement disappears before launch' do + space.organization.update(service_account_prefix: 'task-team') account = ServiceAccountModel.create(name: 'task-worker', space: space, status: 'ready') app.update(service_account: account) account.remove_all_entitled_spaces @@ -81,6 +82,7 @@ module VCAP::CloudController end it 'snapshots the app account on task creation so later unbind cannot change task identity' do + app.space.organization.update(service_account_prefix: 'task-team') 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) diff --git a/spec/unit/jobs/v3/service_account_provision_spec.rb b/spec/unit/jobs/v3/service_account_provision_spec.rb index 36126824e85..80add2b6405 100644 --- a/spec/unit/jobs/v3/service_account_provision_spec.rb +++ b/spec/unit/jobs/v3/service_account_provision_spec.rb @@ -4,7 +4,7 @@ module VCAP::CloudController RSpec.describe 'Service account provisioning job', job_context: :worker do - let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space)) } + let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space, :with_service_account_prefix)) } let(:clients) { instance_double(CF::UAA::Scim) } let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } let(:locator) { CloudController::DependencyLocator.instance } 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 34183d6ed06..740b18ceeb2 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 @@ -61,6 +61,7 @@ module VCAP::CloudController end it 'never exposes account discovery in staging even when supplied by a staging request' do + app.space.organization.update(service_account_prefix: 'staging-team') 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' }) 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 f38aebcb629..cd013ec76b4 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 @@ -358,11 +358,12 @@ module Diego context 'with a launched service account' do let(:config) do - Config.new({ service_account_runtime_enabled: true, + Config.new({ service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, 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 + space.organization.update(service_account_prefix: 'recipe-team') 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) diff --git a/spec/unit/lib/cloud_controller/diego/environment_spec.rb b/spec/unit/lib/cloud_controller/diego/environment_spec.rb index fb2e350f8c2..93efd6cd4cb 100644 --- a/spec/unit/lib/cloud_controller/diego/environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/environment_spec.rb @@ -40,7 +40,9 @@ 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') + process.space.organization.update(service_account_prefix: 'environment-team') + TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, + 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) 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 205a3af30ae..3f166b461d7 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 @@ -3,12 +3,13 @@ module VCAP::CloudController::Diego RSpec.describe 'Service account runtime identity' do - let(:app) { create(:app_model) } + let(:app) { create(:app_model, space: create(:space, :with_service_account_prefix)) } 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') + TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, + service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') app.update(service_account: account) end 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 e7a7c83a649..efc365e325f 100644 --- a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb @@ -43,7 +43,9 @@ module VCAP::CloudController::Diego 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') + app.space.organization.update(service_account_prefix: 'task-environment-team') + TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, + 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) 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 03a4bbfb30b..6a4244a026d 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 @@ -565,6 +565,8 @@ module Diego end it 'propagates a ready service account as typed task certificate properties' do + app.space.organization.update(service_account_prefix: 'task-recipe-team') + config.config_hash[:service_account_runtime_contract_version] = 2 config.config_hash[:service_account_runtime_enabled] = true account = ServiceAccountModel.create(name: 'payments-worker', space: app.space, status: 'ready') app.update(service_account: account) 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 ea4a77e7728..aaca14a7c87 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 @@ -5,7 +5,7 @@ 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_ref: 'diego-instance-identity' } } - let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space)) } + let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: create(:space, :with_service_account_prefix)) } 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) } diff --git a/spec/unit/models/runtime/service_account_assignment_spec.rb b/spec/unit/models/runtime/service_account_assignment_spec.rb index 550e7f69ced..9ff9864f801 100644 --- a/spec/unit/models/runtime/service_account_assignment_spec.rb +++ b/spec/unit/models/runtime/service_account_assignment_spec.rb @@ -2,7 +2,7 @@ module VCAP::CloudController RSpec.describe 'Entitled service account app assignments' do - let(:space) { create(:space) } + let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'assignment-team')) } let(:app) { create(:app_model, space: space) } let(:account) { ServiceAccountModel.create(name: 'payments-worker', space: space) } @@ -38,11 +38,6 @@ module VCAP::CloudController expect(app.reload.space).to eq(space) end - it 'does not permit changing the legacy initial 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) diff --git a/spec/unit/models/runtime/service_account_model_spec.rb b/spec/unit/models/runtime/service_account_model_spec.rb index 055044b32a2..6a86eb46078 100644 --- a/spec/unit/models/runtime/service_account_model_spec.rb +++ b/spec/unit/models/runtime/service_account_model_spec.rb @@ -2,20 +2,21 @@ module VCAP::CloudController RSpec.describe 'Service account registry' do - let(:space) { create(:space) } + let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'registry-team')) } 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 + it 'reserves an org-owned qualified 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.organization).to eq(space.organization) + expect(account.entitled_spaces).to contain_exactly(space) + expect(account.client_id).to eq('cf:service-account:registry-team:payments-worker') + expect(account.certificate_dns_san).to eq('payments-worker.registry-team.svc.identity') expect(account.enabled).to be(true) expect(account.status).to eq('reserved') end @@ -40,7 +41,7 @@ def reserve(name, owner=space) 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 + model.dataset.insert(account.values.except(:id, :guid).merge(guid: SecureRandom.uuid)) # rubocop:disable Rails/SkipsModelValidations end end.to raise_error(Sequel::UniqueConstraintViolation) end @@ -60,9 +61,14 @@ def reserve(name, owner=space) expect { reserve('payments-worker', create(:space, organization: space.organization)) }.to raise_error(Sequel::ValidationFailed, /name/) end - it 'keeps unqualified legacy identities foundation-unique across organizations' do - reserve('legacy-worker') - expect { reserve('legacy-worker', create(:space)) }.to raise_error(Sequel::ValidationFailed, /reserved/) + it 'rejects accounts without an allocated organization prefix' do + expect { reserve('worker-app', create(:space)) }.to raise_error(Sequel::ValidationFailed, /prefix/) + end + + it 'rejects a prefix that does not belong to the owning organization' do + expect do + model.create(name: 'worker-app', space: space, identity_prefix: 'another-team') + end.to raise_error(Sequel::ValidationFailed, /prefix/) end end end diff --git a/spec/unit/models/runtime/service_account_ownership_spec.rb b/spec/unit/models/runtime/service_account_ownership_spec.rb index 86d59c7b32b..35b2417fa58 100644 --- a/spec/unit/models/runtime/service_account_ownership_spec.rb +++ b/spec/unit/models/runtime/service_account_ownership_spec.rb @@ -2,15 +2,15 @@ module VCAP::CloudController RSpec.describe 'Service account organization ownership' do - let(:space) { create(:space) } + let(:space) { create(:space, organization: create(:organization, service_account_prefix: 'ownership-team')) } it 'persists the immutable org owner and initial space entitlement at creation' do account = ServiceAccountModel.create(name: 'payments-worker', space: space) expect(account.reload.organization).to eq(space.organization) expect(account.entitled_spaces).to contain_exactly(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.client_id).to eq('cf:service-account:ownership-team:payments-worker') + expect(account.certificate_dns_san).to eq('payments-worker.ownership-team.svc.identity') end it 'does not duplicate the initial entitlement when metadata or state changes' do @@ -58,20 +58,5 @@ module VCAP::CloudController account.remove_all_entitled_spaces expect { process.update(state: ProcessModel::STARTED) }.to raise_error(Sequel::ValidationFailed, /entitled/) end - - it 'measures known deletion counts and age buckets without assigning dates to historical reservations' do - account = ServiceAccountModel.create(name: 'payments-worker', space: space) - account.destroy - stats = ServiceAccountModel.reservation_statistics - expect(stats).to include(reserved_names: 1, deletions: 1, unknown_deletion_times: 0) - expect(stats[:deletion_age_days]).to include('0-7' => 1) - end - - it 'counts an orphan reservation as unknown even when another org uses the same local name' do - other_org = create(:organization, service_account_prefix: 'stats-team') - ServiceAccountModel.create(name: 'shared-name', space: create(:space, organization: other_org)) - ServiceAccountModel.db[:service_account_names].insert(name: 'shared-name', organization_id: space.organization_id) # rubocop:disable Rails/SkipsModelValidations -- Historical orphan fixture. - expect(ServiceAccountModel.reservation_statistics[:unknown_deletion_times]).to eq(1) - end end end From 14427ac44a57ac199091cf36436291e9c818cbc4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 10:22:48 +0100 Subject: [PATCH 170/200] Test model-owned lookup of qualified account client IDs (red) --- .../username_populator_spec.rb | 14 +++++++++++++ .../runtime/service_account_model_spec.rb | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/spec/unit/collection_transformers/username_populator_spec.rb b/spec/unit/collection_transformers/username_populator_spec.rb index ea845fb9db4..d47f8debf83 100644 --- a/spec/unit/collection_transformers/username_populator_spec.rb +++ b/spec/unit/collection_transformers/username_populator_spec.rb @@ -28,6 +28,20 @@ module VCAP::CloudController username_populator.transform(user1) expect(user1.username).to eq('Username1') end + + it 'delegates qualified account lookup to the model while retaining UAA lookup for other users' do + account = ServiceAccountModel.create(name: 'payments-worker', space: create(:space, :with_service_account_prefix)) + account_user = User.new(guid: account.client_id, is_oauth_client: true) + other_client = User.new(guid: 'other-client', is_oauth_client: true) + expect(ServiceAccountModel).to receive(:for_client_ids).with([account.client_id, other_client.guid]).and_call_original + expect(uaa_client).to receive(:usernames_for_ids).with(%w[1 other-client]).and_return('1' => 'Username1') + + username_populator.transform([account_user, user1, other_client]) + + expect(account_user.username).to eq(account.name) + expect(user1.username).to eq('Username1') + expect(other_client.username).to be_nil + end 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 index 6a86eb46078..03fbd62b93d 100644 --- a/spec/unit/models/runtime/service_account_model_spec.rb +++ b/spec/unit/models/runtime/service_account_model_spec.rb @@ -70,5 +70,26 @@ def reserve(name, owner=space) model.create(name: 'worker-app', space: space, identity_prefix: 'another-team') end.to raise_error(Sequel::ValidationFailed, /prefix/) end + + describe '.for_client_ids' do + it 'selects exact qualified identities without matching other prefix and name combinations' do + other_space = create(:space, organization: create(:organization, service_account_prefix: 'another-team')) + requested = [reserve('payments-worker'), reserve('reporting-reader', other_space)] + reserve('reporting-reader') + reserve('payments-worker', other_space) + + expect(model.for_client_ids(requested.map(&:client_id)).all).to match_array(requested) + end + + it 'returns no accounts for empty, unrelated or malformed identities' do + reserve('payments-worker') + invalid_ids = ['payments-worker', 'cf:service-account:payments-worker', + 'cf:service-account:registry-team:payments-worker:extra', + "cf:service-account:registry-team:payments-worker\n", 'other-client'] + + expect(model.for_client_ids([]).all).to be_empty + expect(model.for_client_ids(invalid_ids).all).to be_empty + end + end end end From 1b10bdfd77b223ef7cb161d305d7b04d39b80ad2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 10:23:03 +0100 Subject: [PATCH 171/200] Resolve qualified client identities through service account model --- app/collection_transformers/username_populator.rb | 4 ++-- app/models/runtime/service_account_model.rb | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/collection_transformers/username_populator.rb b/app/collection_transformers/username_populator.rb index 32d093f7ab7..f3a90adde7c 100644 --- a/app/collection_transformers/username_populator.rb +++ b/app/collection_transformers/username_populator.rb @@ -8,8 +8,8 @@ def initialize(uaa_client) def transform(users, _opts={}) users = Array(users) - names = users.select(&:is_oauth_client).map { |user| user.guid.delete_prefix('cf:service-account:').split(':').last } - accounts = ServiceAccountModel.where(name: names).all + client_ids = users.select(&:is_oauth_client).map(&:guid) + accounts = ServiceAccountModel.for_client_ids(client_ids).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)) diff --git a/app/models/runtime/service_account_model.rb b/app/models/runtime/service_account_model.rb index 50800aea160..ab018c3dc04 100644 --- a/app/models/runtime/service_account_model.rb +++ b/app/models/runtime/service_account_model.rb @@ -2,6 +2,17 @@ module VCAP::CloudController class ServiceAccountModel < Sequel::Model(:service_accounts) NAME_PATTERN = /\A[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\z/ + def self.for_client_ids(client_ids) + identities = client_ids.filter_map do |client_id| + namespace, type, prefix, name, extra = client_id.split(':', -1) + next unless namespace == 'cf' && type == 'service-account' && extra.nil? + next unless NAME_PATTERN.match?(prefix.to_s) && NAME_PATTERN.match?(name.to_s) + + { identity_prefix: prefix, name: name } + end + identities.empty? ? where(false) : where(Sequel.|(*identities)) + end + # Creation input for the initial entitlement; never persisted as ownership. attr_accessor :space From 3b42219e0c06a93664b81b4adf2ca00d7d4e954e Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 11:33:56 +0100 Subject: [PATCH 172/200] Test bounded association queries for service account lists (red) --- spec/request/service_accounts_spec.rb | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index a06ba400d17..6444cd744fc 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -223,6 +223,39 @@ def headers(role) expect(Oj.load(last_response.body)['resources']).to eq([]) end + it 'loads presentation associations once per page rather than once per account' do + auth = headers('admin') + accounts = Array.new(3) do |index| + entitled_space = create(:space, :with_service_account_prefix) + account = VCAP::CloudController::ServiceAccountModel.create(name: "worker-#{index}", space: entitled_space) + VCAP::CloudController::ServiceAccountLabelModel.create(resource_guid: account.guid, key_name: 'team', value: "team-#{index}") + VCAP::CloudController::ServiceAccountAnnotationModel.create(resource_guid: account.guid, key_name: 'note', value: "note-#{index}") + [account, entitled_space] + end + + [1, 3].each do |per_page| + %w[organizations spaces service_account_labels service_account_annotations].each do |table| + expect do + get "/v3/service_accounts?per_page=#{per_page}", nil, auth + end.to have_queried_db_times(/SELECT .* FROM .#{table}. /i, 1) + end + + expect(last_response.status).to eq(200) + resources = Oj.load(last_response.body)['resources'] + expect(resources.size).to eq(per_page) + resources.each do |resource| + account, entitled_space = accounts.find { |entry| entry.first.guid == resource['guid'] } + expect(resource.dig('relationships', 'organization', 'data', 'guid')).to eq(entitled_space.organization.guid) + expect(resource.dig('relationships', 'spaces', 'data')).to eq([{ 'guid' => entitled_space.guid }]) + index = account.name.delete_prefix('worker-') + expect(resource['metadata']).to eq({ + 'labels' => { 'team' => "team-#{index}" }, + 'annotations' => { 'note' => "note-#{index}" } + }) + end + end + end + it 'allows developer 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') From ac242dcedf16a9868790825f76a96f4362d42d08 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 11:34:09 +0100 Subject: [PATCH 173/200] Eager load associations for service account list presentation --- app/controllers/v3/service_accounts_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index a64bd099258..14c07173e33 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -12,7 +12,7 @@ class ServiceAccountsController < ApplicationController def index message = ServiceAccountsListMessage.from_params(query_params) invalid_param!(message.errors.full_messages) unless message.valid? - dataset = ServiceAccountModel.dataset + dataset = ServiceAccountModel.eager(:organization, :entitled_spaces, :labels, :annotations) unless permission_queryer.can_read_globally? entitled = ServiceAccountModel.db[:service_account_space_entitlements].where(space_guid: permission_queryer.readable_space_guids_query).select(:service_account_guid) managed_orgs = current_user.managed_organizations_dataset.select(:id) From e0bf2ab3bcc8cd70de2c706b7fbdb6ee3e8c7aa7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 12:26:49 +0100 Subject: [PATCH 174/200] Test entitlement revocation in nonwritable spaces (red) --- .../service_account_entitlements_spec.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/spec/request/service_account_entitlements_spec.rb b/spec/request/service_account_entitlements_spec.rb index c3c0c50364d..9316d25229a 100644 --- a/spec/request/service_account_entitlements_spec.rb +++ b/spec/request/service_account_entitlements_spec.rb @@ -149,6 +149,32 @@ def entitle(destination, headers) expect(account.reload.entitled_spaces).to contain_exactly(target) end + [Space::SUSPENDED, Space::DELETING].each do |state| + it "prevents a developer from revoking an entitlement in a #{state} space" do + headers = auth('space_developer') + account.add_entitled_space(target) + space.update(status: state) + + delete "#{path}/#{space.guid}", nil, headers + + expect(last_response.status).to eq(state == Space::DELETING ? 422 : 403) + expect(account.reload.entitled_spaces).to contain_exactly(space, target) + end + + %w[org_manager admin].each do |role| + it "allows an #{role} to revoke an unused entitlement in a #{state} space" do + headers = auth(role) + account.add_entitled_space(target) + space.update(status: state) + + delete "#{path}/#{space.guid}", nil, headers + + expect(last_response.status).to eq(200) + expect(account.reload.entitled_spaces).to contain_exactly(target) + end + end + end + it 'does not use a suspended source space to authorize entitlement expansion' do headers = auth('space_developer') target.add_developer(user) From 523bcacd0986a329d150c27bb103b672542e0599 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 12:27:02 +0100 Subject: [PATCH 175/200] Require writable spaces for account entitlement revocation --- app/controllers/v3/service_accounts_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 14c07173e33..f193cad2d0d 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -182,6 +182,7 @@ def unentitle space = account.entitled_spaces_dataset.where(spaces__guid: hashed_params[:space_guid]).first resource_not_found!(:space) unless space unauthorized! unless org_manager?(account) || developer?(space) + require_writable_space!(space) lifecycle_conflict!('cannot revoke the last entitlement; delete the account instead') if !org_manager?(account) && account.entitled_spaces_dataset.one? lifecycle_conflict!('service account is still assigned or in use in this space') if account.in_use_in_space?(space) account.remove_entitled_space(space) From 1032d9f13b2872d43eb49c879acb1b10f639e6d2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 13:32:35 +0100 Subject: [PATCH 176/200] Test account-keyed lifecycle reference indexes and rollback (red) --- ...0_add_service_account_reference_indexes.rb | 9 ++++ ..._service_account_reference_indexes_spec.rb | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 db/migrations/20261112120000_add_service_account_reference_indexes.rb create mode 100644 spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb diff --git a/db/migrations/20261112120000_add_service_account_reference_indexes.rb b/db/migrations/20261112120000_add_service_account_reference_indexes.rb new file mode 100644 index 00000000000..381c0fc26a5 --- /dev/null +++ b/db/migrations/20261112120000_add_service_account_reference_indexes.rb @@ -0,0 +1,9 @@ +Sequel.migration do + no_transaction + + up do + end + + down do + end +end diff --git a/spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb b/spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb new file mode 100644 index 00000000000..78b4211afa5 --- /dev/null +++ b/spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' +require 'migrations/helpers/migration_shared_context' + +RSpec.describe 'service account reference indexes', isolation: :truncation, type: :migration do + include_context 'migration' do + let(:migration_filename) { '20261112120000_add_service_account_reference_indexes.rb' } + end + + it 'adds account-first indexes, supports retry after partial creation and removes them on rollback' do + expected_indexes = { + service_bindings: [:service_bindings_service_account_guid_index, [:service_account_guid]], + processes: [:processes_service_account_guid_state_index, %i[service_account_guid state]], + tasks: [:tasks_service_account_guid_state_index, %i[service_account_guid state]] + } + expected_indexes.each do |table, (name, _columns)| + expect(db.indexes(table)).not_to have_key(name) + end + + Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) + expected_indexes.each do |table, (name, columns)| + expect(db.indexes(table)[name]).to include(columns: columns, unique: false) + end + + # Simulate an interrupted run with the first index already installed. + load File.join(migrations_path, migration_filename) + migration = Sequel::Migration.descendants.last + %i[processes tasks].each do |table| + name, columns = expected_indexes.fetch(table) + db.drop_index(table, columns, name: name, concurrently: db.database_type == :postgres) + end + expect { migration.apply(db, :up) }.not_to raise_error + expected_indexes.each do |table, (name, columns)| + expect(db.indexes(table)[name]).to include(columns: columns) + end + + Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) + expected_indexes.each do |table, (name, _columns)| + expect(db.indexes(table)).not_to have_key(name) + end + expect { migration.apply(db, :down) }.not_to raise_error + end +end From 32fef5c55eb7eaed339c857c83737452b05b355d Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 13:32:55 +0100 Subject: [PATCH 177/200] Index service account references for lifecycle checks --- ...0_add_service_account_reference_indexes.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/db/migrations/20261112120000_add_service_account_reference_indexes.rb b/db/migrations/20261112120000_add_service_account_reference_indexes.rb index 381c0fc26a5..1efe7bd358f 100644 --- a/db/migrations/20261112120000_add_service_account_reference_indexes.rb +++ b/db/migrations/20261112120000_add_service_account_reference_indexes.rb @@ -2,8 +2,30 @@ no_transaction up do + { + service_bindings: [:service_bindings_service_account_guid_index, [:service_account_guid]], + processes: [:processes_service_account_guid_state_index, %i[service_account_guid state]], + tasks: [:tasks_service_account_guid_state_index, %i[service_account_guid state]] + }.each do |table, (name, columns)| + next if indexes(table).key?(name) + + VCAP::Migration.with_concurrent_timeout(self) do + add_index table, columns, name: name, concurrently: database_type == :postgres + end + end end down do + { + service_bindings: :service_bindings_service_account_guid_index, + processes: :processes_service_account_guid_state_index, + tasks: :tasks_service_account_guid_state_index + }.each do |table, name| + next unless indexes(table).key?(name) + + VCAP::Migration.with_concurrent_timeout(self) do + drop_index table, nil, name: name, concurrently: database_type == :postgres + end + end end end From aadbdbad999326ecb5a234436b97e26aad5dcf47 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 14:03:02 +0100 Subject: [PATCH 178/200] Test user-provided bindings remain independent of account identity (red) --- .../service_account_binding_guard_spec.rb | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/unit/actions/service_account_binding_guard_spec.rb b/spec/unit/actions/service_account_binding_guard_spec.rb index 386221c873f..78a5eff5dc2 100644 --- a/spec/unit/actions/service_account_binding_guard_spec.rb +++ b/spec/unit/actions/service_account_binding_guard_spec.rb @@ -3,6 +3,34 @@ module VCAP::CloudController RSpec.describe 'Service account identity binding guard' do + it 'keeps user-provided bindings credential-only and allows account unassignment' do + space = create(:space, :with_service_account_prefix) + account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') + app = create(:app_model, space: space, service_account: account) + credentials = { 'host' => 'database.example.test', 'username' => 'worker' } + binding = create(:service_binding, app: app, + service_instance: create(:user_provided_service_instance, space: space), credentials: credentials) + + expect(binding.reload.service_account_guid).to be_nil + AppAssignServiceAccount.new(double(can_write_to_active_space?: true)).assign(app, nil) + + expect(app.reload.service_account_guid).to be_nil + expect(account.in_use?).to be(false) + expect(account.in_use_in_space?(space)).to be(false) + expect(binding.reload.credentials).to eq(credentials) + end + + it 'does not require account entitlement to create a credential-only user-provided binding' do + space = create(:space, :with_service_account_prefix) + account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') + app = create(:app_model, space: space, service_account: account) + account.remove_all_entitled_spaces + + binding = create(:service_binding, app: app, service_instance: create(:user_provided_service_instance, space: space)) + + expect(binding.reload.service_account_guid).to be_nil + end + it 'rejects binding snapshots whose account entitlement was revoked before persistence' do space = create(:space, :with_service_account_prefix) account = ServiceAccountModel.create(name: 'binding-worker', space: space, status: 'ready') From 8b7dfb6f1d1158c4d35ecf4fd1d5dff54ec42ce8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 14:03:18 +0100 Subject: [PATCH 179/200] Capture account identity only for managed service bindings --- app/models/services/service_binding.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/services/service_binding.rb b/app/models/services/service_binding.rb index 8bd98d697be..6beac00887e 100644 --- a/app/models/services/service_binding.rb +++ b/app/models/services/service_binding.rb @@ -98,7 +98,7 @@ def before_update end def before_create - if app + if app && service_instance.is_a?(ManagedServiceInstance) app.lock! account = app.service_account account&.lock! From a33e5a44c34b40b8585e8a36d2238ae926b2742a Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 15:13:19 +0100 Subject: [PATCH 180/200] Test V2 broker binding identity before persistence (red) --- .../services/service_binding_create_spec.rb | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/spec/unit/actions/v2/services/service_binding_create_spec.rb b/spec/unit/actions/v2/services/service_binding_create_spec.rb index 9315282ab8c..34da920de89 100644 --- a/spec/unit/actions/v2/services/service_binding_create_spec.rb +++ b/spec/unit/actions/v2/services/service_binding_create_spec.rb @@ -42,6 +42,87 @@ module VCAP::CloudController allow(client).to receive(:bind).and_return({ async: false, binding: binding_params, operation: nil }) end + context 'with a service account' do + let(:app) { create(:app_model, space: create(:space, :with_service_account_prefix)) } + let(:account) { ServiceAccountModel.create(name: 'broker-worker', space: app.space, status: 'ready') } + + before { app.update(service_account: account) } + + it 'captures the account before the broker request and persists the same identity' do + expect(client).to receive(:bind) do |binding, **| + expect(binding.service_account_guid).to eq(account.guid) + expect(binding).to be_new + expect(ServiceBinding.db.in_transaction?).to be(true) + { async: false, binding: {}, operation: nil } + end + + binding = service_binding_create.create(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) + + expect(binding.reload.service_account_guid).to eq(account.guid) + end + + it 'rejects a revoked entitlement before contacting the broker' do + account.remove_all_entitled_spaces + + expect do + service_binding_create.create(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) + end.to raise_error(Sequel::ValidationFailed, /entitled/) + expect(client).not_to have_received(:bind) + expect(ServiceBinding.count).to eq(0) + end + + it 'serializes concurrent unassignment until the identity-bearing binding is persisted', isolation: :truncation do + app_guid = app.guid + broker_called = Queue.new + finish_broker = Queue.new + writer_started = Queue.new + allow(client).to receive(:bind) do |binding, **| + broker_called << binding.service_account_guid + finish_broker.pop + { async: false, binding: {}, operation: nil } + end + instance = service_instance + binding_message = message + creator = Thread.new do + service_binding_create.create(AppModel.first(guid: app_guid), instance, binding_message, true, false) + end + expect(Timeout.timeout(10) { broker_called.pop }).to eq(account.guid) + writer = Thread.new do + writer_started << true + AppAssignServiceAccount.new(double(can_write_to_active_space?: true)).assign(AppModel.first(guid: app_guid), nil) + rescue AppAssignServiceAccount::Conflict => e + e + end + Timeout.timeout(10) { writer_started.pop } + finish_broker << true + + binding = Timeout.timeout(10) { creator.value } + expect(Timeout.timeout(10) { writer.value }).to be_a(AppAssignServiceAccount::Conflict) + expect(binding.reload.service_account_guid).to eq(account.guid) + expect(app.reload.service_account_guid).to eq(account.guid) + ensure + finish_broker << true if finish_broker + [creator, writer].compact.each do |thread| + thread.kill if thread.alive? + thread.join + end + end + + it 'does not recapture a later assignment after the broker has received the original identity' do + original_guid = account.guid + expect(client).to receive(:bind) do |binding, **| + expect(binding.service_account_guid).to eq(original_guid) + # A stale caller object must not replace the identity captured under lock. + binding.app.service_account = nil + { async: false, binding: {}, operation: nil } + end + + binding = service_binding_create.create(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) + + expect(binding.reload.service_account_guid).to eq(original_guid) + end + end + context 'when the service broker does not include any additional binding parameters' do it 'creates a plain old Service Binding' do service_binding = service_binding_create.create(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) From 7c9959699299dd4483ca835258d2e827b75121fe Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 15:13:34 +0100 Subject: [PATCH 181/200] Capture and lock V2 service account identity before broker bind --- app/actions/v2/services/service_binding_create.rb | 13 +++++++++++-- app/models/services/service_binding.rb | 12 +++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/actions/v2/services/service_binding_create.rb b/app/actions/v2/services/service_binding_create.rb index cdb06c2807d..227305cc491 100644 --- a/app/actions/v2/services/service_binding_create.rb +++ b/app/actions/v2/services/service_binding_create.rb @@ -24,6 +24,15 @@ def initialize(user_audit_info, manifest_triggered: false) end def create(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) + ServiceBinding.db.transaction(savepoint: true) do + app.lock! if service_instance.is_a?(ManagedServiceInstance) + create_binding(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) + end + end + + private + + def create_binding(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) raise ServiceInstanceNotBindable unless service_instance.bindable? raise VolumeMountServiceDisabled if service_instance.volume_service? && !volume_mount_services_enabled raise SpaceMismatch unless bindable_in_space?(service_instance, app.space) @@ -40,6 +49,8 @@ def create(app, service_instance, message, volume_mount_services_enabled, accept ) raise InvalidServiceBinding.new(binding.errors.full_messages.join(' ')) unless binding.valid? + binding.capture_service_account_identity! + client = VCAP::Services::ServiceClientProvider.provide(instance: service_instance) binding_result = request_binding_from_broker(client, binding, message.parameters, accepts_incomplete) @@ -68,8 +79,6 @@ def create(app, service_instance, message, volume_mount_services_enabled, accept binding end - private - def request_binding_from_broker(client, service_binding, parameters, accepts_incomplete) client.bind(service_binding, arbitrary_parameters: parameters, accepts_incomplete: accepts_incomplete) end diff --git a/app/models/services/service_binding.rb b/app/models/services/service_binding.rb index 6beac00887e..d0ad68809c5 100644 --- a/app/models/services/service_binding.rb +++ b/app/models/services/service_binding.rb @@ -98,18 +98,24 @@ def before_update end def before_create + capture_service_account_identity! unless @service_account_identity_captured + encode_syslog_drain_url_commas + super + end + + def capture_service_account_identity! if app && service_instance.is_a?(ManagedServiceInstance) app.lock! account = app.service_account account&.lock! - if account && !account.entitled_to?(app.space) + if account && !(account.organization_id == app.space.organization_id && + account.db[:service_account_space_entitlements].where(service_account_guid: account.guid, space_guid: app.space_guid).for_update.first) errors.add(:service_account, 'must be entitled to the app space') raise validation_failed_error end self.service_account_guid = account&.guid end - encode_syslog_drain_url_commas - super + @service_account_identity_captured = true end def encode_syslog_drain_url_commas From 07291304c98cbba68aac7f31a0e6f10ee0e1e227 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 15:29:47 +0100 Subject: [PATCH 182/200] Test recovery of partially applied app account migration (red) --- ...1005120100_add_app_service_account_spec.rb | 42 +++++++++++++++++++ spec/unit/service_account_migration_spec.rb | 2 + 2 files changed, 44 insertions(+) create mode 100644 spec/migrations/20261005120100_add_app_service_account_spec.rb diff --git a/spec/migrations/20261005120100_add_app_service_account_spec.rb b/spec/migrations/20261005120100_add_app_service_account_spec.rb new file mode 100644 index 00000000000..091084926b9 --- /dev/null +++ b/spec/migrations/20261005120100_add_app_service_account_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +RSpec.describe 'app service account relationship migration recovery', isolation: :truncation, type: :migration do + it 'resumes after index creation failure and preserves an already valid index' do + db = Sequel::Model.db + load File.join(DBMigrator::SEQUEL_MIGRATIONS, '20261005120100_add_app_service_account.rb') + migration = Sequel::Migration.descendants.last + migration.apply(db, :down) + allow(db).to receive(:add_index).and_call_original + allow(db).to receive(:add_index).with(:apps, :service_account_guid, anything).and_raise(Sequel::DatabaseError, 'index creation interrupted') + + expect { migration.apply(db, :up) }.to raise_error(Sequel::DatabaseError, 'index creation interrupted') + expect(db.schema(:apps).to_h).to have_key(:service_account_guid) + expect(db.foreign_key_list(:apps)).to include(hash_including(name: :fk_apps_service_account_guid)) + allow(db).to receive(:add_index).and_call_original + + expect { migration.apply(db, :up) }.not_to raise_error + expect(db.indexes(:apps)).to have_key(:apps_service_account_guid_index) + expect(db).not_to receive(:add_index) + expect(db).not_to receive(:drop_index) + expect { migration.apply(db, :up) }.not_to raise_error + ensure + allow(db).to receive(:add_index).and_call_original + allow(db).to receive(:drop_index).and_call_original + migration.apply(db, :up) unless db.indexes(:apps).key?(:apps_service_account_guid_index) + end + + it 'rebuilds an invalid PostgreSQL concurrent index' do + db = Sequel::Model.db + skip 'PostgreSQL concurrent index validity' unless db.database_type == :postgres + + load File.join(DBMigrator::SEQUEL_MIGRATIONS, '20261005120100_add_app_service_account.rb') + migration = Sequel::Migration.descendants.last + db.run("UPDATE pg_index SET indisvalid = false WHERE indexrelid = 'apps_service_account_guid_index'::regclass") + + migration.apply(db, :up) + + expect(db.fetch("SELECT indisvalid FROM pg_index WHERE indexrelid = 'apps_service_account_guid_index'::regclass").get(:indisvalid)).to be(true) + ensure + db.run("UPDATE pg_index SET indisvalid = true WHERE indexrelid = 'apps_service_account_guid_index'::regclass") if db&.database_type == :postgres + end +end diff --git a/spec/unit/service_account_migration_spec.rb b/spec/unit/service_account_migration_spec.rb index d390c561a63..755fae9d489 100644 --- a/spec/unit/service_account_migration_spec.rb +++ b/spec/unit/service_account_migration_spec.rb @@ -4,6 +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 }]]) + allow(db).to receive(:foreign_key_list).with(:apps).and_return([]) + allow(db).to receive(:indexes).with(:apps).and_return({}) load File.expand_path('../../db/migrations/20261005120100_add_app_service_account.rb', __dir__) migration = Sequel::Migration.descendants.last migration.apply(db, :up) From 9ddfe25cd27f5f0910d384bd49731c07516c16a4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 15:30:01 +0100 Subject: [PATCH 183/200] Resume app account migration after foreign key or index interruption --- .../20261005120100_add_app_service_account.rb | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/db/migrations/20261005120100_add_app_service_account.rb b/db/migrations/20261005120100_add_app_service_account.rb index 7f7c87a7ec4..791fdca4444 100644 --- a/db/migrations/20261005120100_add_app_service_account.rb +++ b/db/migrations/20261005120100_add_app_service_account.rb @@ -8,16 +8,23 @@ options[:collate] = parent[:Collation] || parent[:collation] end existing_column = schema(:apps).any? { |name, _| name == :service_account_guid } + existing_foreign_key = foreign_key_list(:apps).any? { |key| key[:name] == :fk_apps_service_account_guid } alter_table :apps do - if existing_column + if existing_column && !existing_foreign_key set_column_type :service_account_guid, String, **options, size: 255 - else + elsif !existing_column 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 + add_foreign_key [:service_account_guid], :service_accounts, key: :guid, name: :fk_apps_service_account_guid unless existing_foreign_key end VCAP::Migration.with_concurrent_timeout(self) do - add_index :apps, :service_account_guid, name: :apps_service_account_guid_index, concurrently: database_type == :postgres + if database_type == :postgres + index_valid = fetch("SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass('apps_service_account_guid_index')").get(:indisvalid) + drop_index :apps, :service_account_guid, name: :apps_service_account_guid_index, concurrently: true if index_valid == false + add_index :apps, :service_account_guid, name: :apps_service_account_guid_index, concurrently: true unless index_valid + elsif !indexes(:apps).key?(:apps_service_account_guid_index) + add_index :apps, :service_account_guid, name: :apps_service_account_guid_index, concurrently: false + end end end From 27fe3eef4723c5ef3c6f86bbcde493915d1c6f84 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 15:41:31 +0100 Subject: [PATCH 184/200] Preserve V2 orphan cleanup jobs and fix list spec formatting --- app/actions/v2/services/service_binding_create.rb | 14 +++++++++++++- spec/request/service_accounts_spec.rb | 9 +++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/actions/v2/services/service_binding_create.rb b/app/actions/v2/services/service_binding_create.rb index 227305cc491..5e80448b5ab 100644 --- a/app/actions/v2/services/service_binding_create.rb +++ b/app/actions/v2/services/service_binding_create.rb @@ -24,10 +24,22 @@ def initialize(user_audit_info, manifest_triggered: false) end def create(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) - ServiceBinding.db.transaction(savepoint: true) do + broker_error = nil + binding = ServiceBinding.db.transaction(savepoint: true) do app.lock! if service_instance.is_a?(ManagedServiceInstance) create_binding(app, service_instance, message, volume_mount_services_enabled, accepts_incomplete) + rescue VCAP::Services::ServiceBrokers::V2::Errors::HttpClientTimeout, + VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerBadResponse, + VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerInvalidVolumeMounts, + VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerInvalidSyslogDrainUrl, + VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerResponseMalformed => e + # The broker client enqueues orphan mitigation before raising. Preserve + # those jobs; no binding has been persisted for these broker failures. + broker_error = e end + raise broker_error if broker_error + + binding end private diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 6444cd744fc..3fb52bcab44 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -248,10 +248,11 @@ def headers(role) expect(resource.dig('relationships', 'organization', 'data', 'guid')).to eq(entitled_space.organization.guid) expect(resource.dig('relationships', 'spaces', 'data')).to eq([{ 'guid' => entitled_space.guid }]) index = account.name.delete_prefix('worker-') - expect(resource['metadata']).to eq({ - 'labels' => { 'team' => "team-#{index}" }, - 'annotations' => { 'note' => "note-#{index}" } - }) + expected_metadata = { + 'labels' => { 'team' => "team-#{index}" }, + 'annotations' => { 'note' => "note-#{index}" } + } + expect(resource['metadata']).to eq(expected_metadata) end end end From 50a88d68cf03bad455c7156661f51f1bc349d8c0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 15:59:56 +0100 Subject: [PATCH 185/200] Test invalid concurrent account reference index recovery (red) --- ..._service_account_reference_indexes_spec.rb | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb b/spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb index 78b4211afa5..1cfcb18cf73 100644 --- a/spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb +++ b/spec/migrations/20261112120000_add_service_account_reference_indexes_spec.rb @@ -39,4 +39,29 @@ end expect { migration.apply(db, :down) }.not_to raise_error end + + it 'repairs invalid PostgreSQL indexes while preserving valid indexes on subsequent retries' do + skip 'PostgreSQL concurrent index validity' unless db.database_type == :postgres + + names = %w[service_bindings_service_account_guid_index processes_service_account_guid_state_index tasks_service_account_guid_state_index] + Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) + load File.join(migrations_path, migration_filename) + migration = Sequel::Migration.descendants.last + names.each do |name| + db.run("UPDATE pg_index SET indisvalid = false WHERE indexrelid = '#{name}'::regclass") + end + + migration.apply(db, :up) + + names.each do |name| + expect(db.fetch("SELECT indisvalid FROM pg_index WHERE indexrelid = '#{name}'::regclass").get(:indisvalid)).to be(true) + end + expect(db).not_to receive(:add_index) + expect(db).not_to receive(:drop_index) + migration.apply(db, :up) + ensure + names&.each do |name| + db.run("UPDATE pg_index SET indisvalid = true WHERE indexrelid = '#{name}'::regclass") + end + end end From a47a5cba3a5513c63e2294875c73f55e1e7d70ae Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 16:00:10 +0100 Subject: [PATCH 186/200] Repair invalid PostgreSQL service account reference indexes on retry --- ...00_add_service_account_reference_indexes.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/db/migrations/20261112120000_add_service_account_reference_indexes.rb b/db/migrations/20261112120000_add_service_account_reference_indexes.rb index 1efe7bd358f..9f5c2d186d0 100644 --- a/db/migrations/20261112120000_add_service_account_reference_indexes.rb +++ b/db/migrations/20261112120000_add_service_account_reference_indexes.rb @@ -7,10 +7,14 @@ processes: [:processes_service_account_guid_state_index, %i[service_account_guid state]], tasks: [:tasks_service_account_guid_state_index, %i[service_account_guid state]] }.each do |table, (name, columns)| - next if indexes(table).key?(name) - VCAP::Migration.with_concurrent_timeout(self) do - add_index table, columns, name: name, concurrently: database_type == :postgres + if database_type == :postgres + index_valid = fetch('SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass(?)', name.to_s).get(:indisvalid) + drop_index table, columns, name: name, concurrently: true if index_valid == false + add_index table, columns, name: name, concurrently: true unless index_valid + elsif !indexes(table).key?(name) + add_index table, columns, name: name, concurrently: false + end end end end @@ -21,10 +25,12 @@ processes: :processes_service_account_guid_state_index, tasks: :tasks_service_account_guid_state_index }.each do |table, name| - next unless indexes(table).key?(name) - VCAP::Migration.with_concurrent_timeout(self) do - drop_index table, nil, name: name, concurrently: database_type == :postgres + if database_type == :postgres + drop_index table, nil, name: name, concurrently: true, if_exists: true + elsif indexes(table).key?(name) + drop_index table, nil, name: name, concurrently: false + end end end end From 648f47d7e1dc6a97e9f4f04d88930b593b856454 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 18:51:24 +0100 Subject: [PATCH 187/200] Test recursive V2 org service account cleanup and retry (red) --- .../runtime/organizations_controller_spec.rb | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/spec/unit/controllers/runtime/organizations_controller_spec.rb b/spec/unit/controllers/runtime/organizations_controller_spec.rb index 25e9eaf626f..6d01a947ca2 100644 --- a/spec/unit/controllers/runtime/organizations_controller_spec.rb +++ b/spec/unit/controllers/runtime/organizations_controller_spec.rb @@ -1389,6 +1389,66 @@ def decoded_guids end context 'with recursive=true' do + context 'with service accounts' do + let(:org) { create(:organization, service_account_prefix: 'v2-delete-team') } + let(:space) { create(:space, organization: org) } + let!(:account) { ServiceAccountModel.create(name: 'worker-app', space: space) } + let(:clients) { instance_double(CF::UAA::Scim) } + let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } + + before do + allow(clients).to receive(:get).and_raise(CF::UAA::NotFound) + allow(clients).to receive(:add) + allow(clients).to receive(:delete) + CloudController::DependencyLocator.instance.register(:service_account_provisioner, provisioner) + end + + it 'cleans an unused reservation without resolving UAA provisioning' do + expect(CloudController::DependencyLocator.instance).not_to receive(:service_account_provisioner) + + delete "/v2/organizations/#{org.guid}?recursive=true" + + expect(last_response).to have_status_code(204) + expect(ServiceAccountModel.first(guid: account.guid)).to be_nil + expect(Organization.first(guid: org.guid)).to be_nil + expect(ServiceAccountModel.db[:service_account_name_deletions].where(account_guid: account.guid)).not_to be_empty + end + + it 'removes the managed client before deleting the account and organization' do + provisioner.provision(account) + allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) + + delete "/v2/organizations/#{org.guid}?recursive=true" + + expect(last_response).to have_status_code(204) + expect(clients).to have_received(:delete).with(:client, account.client_id) + expect(ServiceAccountModel.first(guid: account.guid)).to be_nil + expect(Organization.first(guid: org.guid)).to be_nil + event = Event.first(type: 'audit.service_account.delete', actee: account.guid) + expect(event.actor).to eq(SecurityContext.current_user.guid) + end + + it 'retains a deleting organization after UAA failure and permits cleanup retry' do + provisioner.provision(account) + allow(clients).to receive(:get).and_raise('UAA unavailable') + + delete "/v2/organizations/#{org.guid}?recursive=true" + + expect(last_response.status).to be >= 400 + expect(org.reload.status).to eq(Organization::DELETING) + expect(Space.first(guid: space.guid)).to be_nil + expect(account.reload.status).to eq('failed') + allow(clients).to receive(:get).and_return(provisioner.send(:registration, account)) + + delete "/v2/organizations/#{org.guid}?recursive=true" + + expect(last_response).to have_status_code(204) + expect(clients).to have_received(:delete).with(:client, account.client_id) + expect(ServiceAccountModel.first(guid: account.guid)).to be_nil + expect(Organization.first(guid: org.guid)).to be_nil + end + end + it 'deletes the org and all of its spaces' do space_1 = create(:space, organization: org) space_2 = create(:space, organization: org) From 7dc365287d4d9c690d6794d73b91ebc3b54e04b7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 18:51:41 +0100 Subject: [PATCH 188/200] Share retryable service account cleanup with V2 org deletion --- app/actions/organization_delete.rb | 51 +---------------- .../organization_service_account_cleanup.rb | 56 +++++++++++++++++++ app/actions/v2/organization_delete.rb | 15 ++++- .../runtime/organizations_controller.rb | 2 +- .../actions/v2/organization_delete_spec.rb | 2 +- 5 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 app/actions/organization_service_account_cleanup.rb diff --git a/app/actions/organization_delete.rb b/app/actions/organization_delete.rb index 0df3c0c7e77..f9a351040ff 100644 --- a/app/actions/organization_delete.rb +++ b/app/actions/organization_delete.rb @@ -1,9 +1,12 @@ require 'actions/space_delete' require 'actions/label_delete' require 'actions/annotation_delete' +require 'actions/organization_service_account_cleanup' module VCAP::CloudController class OrganizationDelete + include OrganizationServiceAccountCleanup + def initialize(space_deleter, user_audit_info) @space_deleter = space_deleter @user_audit_info = user_audit_info @@ -53,54 +56,6 @@ def timeout_error(dataset) private - def shared_domain_errors(org) - return [] unless ServiceAccountModel.where(organization_id: org.id).any? - - domains = org.owned_private_domains_dataset.all.select(&:shared_with_any_orgs?) - return [] if domains.empty? - - details = domains.map { |domain| "Domain '#{domain.name}' is shared with other organizations. Unshare before deleting." }.join("\n") - [CloudController::Errors::ApiError.new_from_details('OrganizationDeletionFailed', org.name, details)] - end - - def mark_deleting_for_account_cleanup(org) - return unless ServiceAccountModel.where(organization_id: org.id).any? - - org.db.transaction do - org.lock! - org.update(status: Organization::DELETING) - end - end - - def delete_service_accounts(org) - accounts = ServiceAccountModel.where(organization_id: org.id).order(:guid).all - return [] if accounts.empty? - - accounts.each_with_object([]) do |account, errors| - locally_deleted = false - account.db.transaction do - 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 - - if account.status == 'reserved' && !account.in_use? - Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) - account.destroy - locally_deleted = true - else - account.update(enabled: false, status: 'deleting') - end - end - next if locally_deleted - - Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) - CloudController::DependencyLocator.instance.service_account_provisioner.deprovision(account, delete: true) - rescue StandardError => e - errors << e - end - end - def unshare_private_domains(domains_to_unshare, org_model) # find all private domains that are shared with this org # for each one, unshare the org diff --git a/app/actions/organization_service_account_cleanup.rb b/app/actions/organization_service_account_cleanup.rb new file mode 100644 index 00000000000..c3d96f5d222 --- /dev/null +++ b/app/actions/organization_service_account_cleanup.rb @@ -0,0 +1,56 @@ +require 'actions/service_account_provision' +require 'repositories/service_account_event_repository' + +module VCAP::CloudController + module OrganizationServiceAccountCleanup + private + + def shared_domain_errors(org) + return [] unless ServiceAccountModel.where(organization_id: org.id).any? + + domains = org.owned_private_domains_dataset.all.select(&:shared_with_any_orgs?) + return [] if domains.empty? + + details = domains.map { |domain| "Domain '#{domain.name}' is shared with other organizations. Unshare before deleting." }.join("\n") + [CloudController::Errors::ApiError.new_from_details('OrganizationDeletionFailed', org.name, details)] + end + + def mark_deleting_for_account_cleanup(org) + return unless ServiceAccountModel.where(organization_id: org.id).any? + + org.db.transaction do + org.lock! + org.update(status: Organization::DELETING) + end + end + + def delete_service_accounts(org) + accounts = ServiceAccountModel.where(organization_id: org.id).order(:guid).all + return [] if accounts.empty? + + accounts.each_with_object([]) do |account, errors| + locally_deleted = false + account.db.transaction do + 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 + + if account.status == 'reserved' && !account.in_use? + Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) + account.destroy + locally_deleted = true + else + account.update(enabled: false, status: 'deleting') + end + end + next if locally_deleted + + Repositories::ServiceAccountEventRepository.record(account, 'delete', @user_audit_info, { recursive: true }) + CloudController::DependencyLocator.instance.service_account_provisioner.deprovision(account, delete: true) + rescue StandardError => e + errors << e + end + end + end +end diff --git a/app/actions/v2/organization_delete.rb b/app/actions/v2/organization_delete.rb index 7d463fa17a1..eae8147feba 100644 --- a/app/actions/v2/organization_delete.rb +++ b/app/actions/v2/organization_delete.rb @@ -1,22 +1,35 @@ require 'actions/space_delete' require 'actions/label_delete' require 'actions/annotation_delete' +require 'actions/organization_service_account_cleanup' module VCAP::CloudController module V2 class OrganizationDelete - def initialize(space_deleter) + include OrganizationServiceAccountCleanup + + def initialize(space_deleter, user_audit_info) @space_deleter = space_deleter + @user_audit_info = user_audit_info end def delete(org_dataset) org_dataset.each do |org| + shared_errors = shared_domain_errors(org) + return shared_errors unless shared_errors.empty? + + mark_deleting_for_account_cleanup(org) errs = @space_deleter.delete(org.spaces_dataset) unless errs.empty? error_message = errs.map(&:message).join("\n\n") return [CloudController::Errors::ApiError.new_from_details('OrganizationDeletionFailed', org.name, error_message)] end + account_errors = delete_service_accounts(org) + unless account_errors.empty? + return [CloudController::Errors::ApiError.new_from_details('OrganizationDeletionFailed', org.name, account_errors.map(&:message).join("\n\n"))] + end + Organization.db.transaction do org.destroy end diff --git a/app/controllers/runtime/organizations_controller.rb b/app/controllers/runtime/organizations_controller.rb index 571aa054d5d..20e59b6ab0b 100644 --- a/app/controllers/runtime/organizations_controller.rb +++ b/app/controllers/runtime/organizations_controller.rb @@ -268,7 +268,7 @@ def delete(guid) raise CloudController::Errors::ApiError.new_from_details('AssociationNotEmpty', 'spaces', Organization.table_name) if org.spaces_dataset.any? && !recursive_delete? space_delete_action = SpaceDelete.new(UserAuditInfo.from_context(SecurityContext), @services_event_repository) - delete_action = V2::OrganizationDelete.new(space_delete_action) + delete_action = V2::OrganizationDelete.new(space_delete_action, UserAuditInfo.from_context(SecurityContext)) deletion_job = VCAP::CloudController::Jobs::DeleteActionJob.new(Organization, guid, delete_action) response = run_or_enqueue_deletion_job(deletion_job) diff --git a/spec/unit/actions/v2/organization_delete_spec.rb b/spec/unit/actions/v2/organization_delete_spec.rb index fd670b44885..1e0a9dfdc70 100644 --- a/spec/unit/actions/v2/organization_delete_spec.rb +++ b/spec/unit/actions/v2/organization_delete_spec.rb @@ -9,7 +9,7 @@ module V2 let(:user_audit_info) { UserAuditInfo.new(user_guid: user.guid, user_email: user_email) } let(:space_delete) { SpaceDelete.new(user_audit_info, services_event_repository) } - subject(:org_delete) { V2::OrganizationDelete.new(space_delete) } + subject(:org_delete) { V2::OrganizationDelete.new(space_delete, user_audit_info) } describe '#delete' do let!(:org_1) { create(:organization) } let!(:org_2) { create(:organization) } From 9f69c86973261d3e7400c9e421400a5ee11e76f4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 19:28:01 +0100 Subject: [PATCH 189/200] Test single service account activation and updater worker restart (red) --- spec/request/app_service_accounts_spec.rb | 2 +- spec/request/service_accounts_spec.rb | 2 +- spec/unit/actions/organization_delete_spec.rb | 2 +- spec/unit/actions/space_delete_spec.rb | 2 +- .../actions/finalize_spec.rb | 25 +++++++++++++++++++ .../diego/app_recipe_builder_spec.rb | 2 +- .../diego/environment_spec.rb | 2 +- ...qualified_service_account_identity_spec.rb | 8 +++--- .../diego/service_account_identity_spec.rb | 4 +-- .../diego/task_environment_spec.rb | 2 +- .../diego/task_recipe_builder_spec.rb | 3 +-- 11 files changed, 39 insertions(+), 15 deletions(-) diff --git a/spec/request/app_service_accounts_spec.rb b/spec/request/app_service_accounts_spec.rb index f893847dfbe..eb34eb22ba8 100644 --- a/spec/request/app_service_accounts_spec.rb +++ b/spec/request/app_service_accounts_spec.rb @@ -75,7 +75,7 @@ def headers(role) let(:clients) { instance_double(CF::UAA::Scim) } before do - TestConfig.override(service_account_provisioning_enabled: true) + TestConfig.override(service_accounts_enabled: true) CloudController::DependencyLocator.instance.register( :service_account_provisioner, VCAP::CloudController::ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 3fb52bcab44..06eaf8e58e1 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -288,7 +288,7 @@ def headers(role) let(:account_path) { "/v3/service_accounts/#{account.guid}" } before do - TestConfig.override(service_account_provisioning_enabled: true) + TestConfig.override(service_accounts_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) diff --git a/spec/unit/actions/organization_delete_spec.rb b/spec/unit/actions/organization_delete_spec.rb index 4f3a9829c80..9afe3c07d75 100644 --- a/spec/unit/actions/organization_delete_spec.rb +++ b/spec/unit/actions/organization_delete_spec.rb @@ -82,7 +82,7 @@ module VCAP::CloudController let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } before do - TestConfig.override(service_account_provisioning_enabled: true) + TestConfig.override(service_accounts_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) diff --git a/spec/unit/actions/space_delete_spec.rb b/spec/unit/actions/space_delete_spec.rb index b8fd5cba448..5f7f2d2745f 100644 --- a/spec/unit/actions/space_delete_spec.rb +++ b/spec/unit/actions/space_delete_spec.rb @@ -36,7 +36,7 @@ module VCAP::CloudController let(:provisioner) { ServiceAccountProvision.new(clients, identity_ca_ref: 'diego-instance-identity') } before do - TestConfig.override(service_account_provisioning_enabled: true) + TestConfig.override(service_accounts_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) diff --git a/spec/unit/lib/cloud_controller/deployment_updater/actions/finalize_spec.rb b/spec/unit/lib/cloud_controller/deployment_updater/actions/finalize_spec.rb index 72a5c6e9652..0c317117bf8 100644 --- a/spec/unit/lib/cloud_controller/deployment_updater/actions/finalize_spec.rb +++ b/spec/unit/lib/cloud_controller/deployment_updater/actions/finalize_spec.rb @@ -94,6 +94,31 @@ module VCAP::CloudController expect(old_worker_process.reload.command).to eq('new_worker_command') end + it 'restarts account-bound workers using deployment-updater identity configuration' do + app.space.organization.update(service_account_prefix: 'updater-team') + account = ServiceAccountModel.create(name: 'worker-app', space: app.space, status: 'ready') + app.update(service_account: account) + settings = TestConfig.config.merge( + service_accounts_enabled: true, + service_account_token_endpoint: 'https://uaa.example.test/oauth/mtls/token' + ) + updater_config = Config.new(settings, context: :deployment_updater) + allow(Config).to receive(:config).and_return(updater_config) + allow(ProcessRestart).to receive(:restart).and_call_original + runner = double(stop: nil) + allow_any_instance_of(Runners).to receive(:runner_for_process).and_return(runner) + expect(runner).to receive(:start).twice do + identity = Diego::ServiceAccountIdentity.new(app, updater_config) + expect(identity.certificate_properties.service_account.prefix).to eq('updater-team') + expect(identity.environment.fetch('VCAP_SERVICE_ACCOUNT')[:token_endpoint]).to eq('https://uaa.example.test/oauth/mtls/token') + end + + finalize_action.call + + expect(deployment.reload.state).to eq(DeploymentModel::DEPLOYED_STATE) + expect(old_worker_process.reload.runtime_service_account_guid).to eq(account.guid) + end + it 'puts the deployment into its finished DEPLOYED_STATE' do subject.call deployment.reload 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 cd013ec76b4..c7bbe4a8df2 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 @@ -358,7 +358,7 @@ module Diego context 'with a launched service account' do let(:config) do - Config.new({ service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, + Config.new({ service_accounts_enabled: true, diego: { use_privileged_containers_for_running: false, lifecycle_bundles: { 'potato-stack' => 'some-uri' }, pid_limit: 100 } }) end diff --git a/spec/unit/lib/cloud_controller/diego/environment_spec.rb b/spec/unit/lib/cloud_controller/diego/environment_spec.rb index 93efd6cd4cb..236521bad96 100644 --- a/spec/unit/lib/cloud_controller/diego/environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/environment_spec.rb @@ -41,7 +41,7 @@ module VCAP::CloudController::Diego it 'overrides injected account discovery with the ready platform account' do process.space.organization.update(service_account_prefix: 'environment-team') - TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, + TestConfig.override(service_accounts_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) diff --git a/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb index bdadf746851..b38a2d9f9e8 100644 --- a/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/qualified_service_account_identity_spec.rb @@ -10,20 +10,20 @@ module VCAP::CloudController::Diego account = VCAP::CloudController::ServiceAccountModel.create(name: 'worker-app', space: source, status: 'ready') account.add_entitled_space(target) app = create(:app_model, space: target, service_account: account) - TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2) + TestConfig.override(service_accounts_enabled: true) properties = ServiceAccountIdentity.new(app, TestConfig.config_instance).certificate_properties expect(properties.service_account.to_h).to include(name: 'worker-app', prefix: 'payments-team') end - it 'fails closed for qualified identity until the operator enables contract version 2' do + it 'fails closed until service accounts are enabled' do org = create(:organization, service_account_prefix: 'payments-team') space = create(:space, organization: org) account = VCAP::CloudController::ServiceAccountModel.create(name: 'worker-app', space: space, status: 'ready') app = create(:app_model, space: space, service_account: account) - TestConfig.override(service_account_runtime_enabled: true) + TestConfig.override(service_accounts_enabled: false) expect { ServiceAccountIdentity.new(app, TestConfig.config_instance).certificate_properties }. - to raise_error(CloudController::Errors::ApiError, /qualified.*contract/) + to raise_error(CloudController::Errors::ApiError, /not enabled/) 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 3f166b461d7..8722dfe821c 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 @@ -8,7 +8,7 @@ module VCAP::CloudController::Diego let(:identity) { VCAP::CloudController::Diego.const_get(:ServiceAccountIdentity).new(app, TestConfig.config_instance) } before do - TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, + TestConfig.override(service_accounts_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/token/mtls') app.update(service_account: account) end @@ -41,7 +41,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.config[:service_account_runtime_enabled] = false + TestConfig.config[:service_accounts_enabled] = false expect { identity.certificate_properties }.to raise_error(CloudController::Errors::ApiError, /runtime is not enabled/) end 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 efc365e325f..47cb0343204 100644 --- a/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_environment_spec.rb @@ -44,7 +44,7 @@ module VCAP::CloudController::Diego it 'inherits the ready platform account discovery and overrides injected values' do app.space.organization.update(service_account_prefix: 'task-environment-team') - TestConfig.override(service_account_runtime_enabled: true, service_account_runtime_contract_version: 2, + TestConfig.override(service_accounts_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) 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 6a4244a026d..d9ca99d9832 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 @@ -566,8 +566,7 @@ module Diego it 'propagates a ready service account as typed task certificate properties' do app.space.organization.update(service_account_prefix: 'task-recipe-team') - config.config_hash[:service_account_runtime_contract_version] = 2 - config.config_hash[:service_account_runtime_enabled] = true + config.config_hash[:service_accounts_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) From 12534ae4a50874065e9230ba0fd4ff509ea2a209 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 19:28:17 +0100 Subject: [PATCH 190/200] Use one service account enable setting across CAPI processes --- app/controllers/v3/app_service_accounts_controller.rb | 2 +- app/controllers/v3/service_accounts_controller.rb | 2 +- docs/v3/source/includes/api_resources/_service_accounts.md | 6 +++--- lib/cloud_controller/config_schemas/api_schema.rb | 4 +--- lib/cloud_controller/config_schemas/clock_schema.rb | 3 +-- .../config_schemas/deployment_updater_schema.rb | 2 ++ lib/cloud_controller/config_schemas/worker_schema.rb | 3 +-- lib/cloud_controller/diego/service_account_identity.rb | 5 +---- 8 files changed, 11 insertions(+), 16 deletions(-) diff --git a/app/controllers/v3/app_service_accounts_controller.rb b/app/controllers/v3/app_service_accounts_controller.rb index b0690fa1e37..d12dd167d93 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, actor: user_audit_info).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_accounts_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 f193cad2d0d..3c1b976955c 100644 --- a/app/controllers/v3/service_accounts_controller.rb +++ b/app/controllers/v3/service_accounts_controller.rb @@ -212,7 +212,7 @@ def readable?(account) end def require_provisioning! - lifecycle_conflict!('service account provisioning is not enabled') unless Config.config.get(:service_account_provisioning_enabled) == true + lifecycle_conflict!('service accounts are not enabled') unless Config.config.get(:service_accounts_enabled) == true end def reject_active_operation!(account) diff --git a/docs/v3/source/includes/api_resources/_service_accounts.md b/docs/v3/source/includes/api_resources/_service_accounts.md index d40f223de1a..dc722e3c806 100644 --- a/docs/v3/source/includes/api_resources/_service_accounts.md +++ b/docs/v3/source/includes/api_resources/_service_accounts.md @@ -118,9 +118,9 @@ certificate-only route access. BOSH `cc.service_accounts` properties: -- `provisioning_enabled`, `runtime_enabled`: default false. -- `runtime_contract_version`: default 0; set 2 only when all BBS/rep/cells preserve - prefix/name and issue qualified SANs. Qualified launches fail closed otherwise. +- `enabled`: default false; renders `service_accounts_enabled` for API, worker, + clock and deployment updater. Enable only after compatible UAA/BBS/Diego and + routing components are deployed. There is no numeric contract-version setting. - `token_endpoint`: HTTPS UAA mTLS endpoint, no credentials/query/fragment. - Worker-only `management_client_id`, `management_client_secret`, `identity_ca_ref`. Inline CA certificates are unsupported. Rotate named bundle contents in UAA. diff --git a/lib/cloud_controller/config_schemas/api_schema.rb b/lib/cloud_controller/config_schemas/api_schema.rb index 78d63e2a0e9..e2af80fc37e 100644 --- a/lib/cloud_controller/config_schemas/api_schema.rb +++ b/lib/cloud_controller/config_schemas/api_schema.rb @@ -30,9 +30,7 @@ class ApiSchema < VCAP::Config optional(:custom) => Hash }, optional(:custom_root_links) => Array, - optional(:service_account_provisioning_enabled) => bool, - optional(:service_account_runtime_enabled) => bool, - optional(:service_account_runtime_contract_version) => Integer, + optional(:service_accounts_enabled) => bool, optional(:service_account_creation_limit) => Integer, optional(:service_account_token_endpoint) => String, optional(:service_account_provisioning) => { diff --git a/lib/cloud_controller/config_schemas/clock_schema.rb b/lib/cloud_controller/config_schemas/clock_schema.rb index a3534119335..a0205ff2c6f 100644 --- a/lib/cloud_controller/config_schemas/clock_schema.rb +++ b/lib/cloud_controller/config_schemas/clock_schema.rb @@ -6,8 +6,7 @@ class ClockSchema < VCAP::Config # rubocop:disable Metrics/BlockLength define_schema do { - optional(:service_account_runtime_enabled) => bool, - optional(:service_account_runtime_contract_version) => Integer, + optional(:service_accounts_enabled) => bool, optional(:service_account_token_endpoint) => String, external_port: Integer, external_domain: String, diff --git a/lib/cloud_controller/config_schemas/deployment_updater_schema.rb b/lib/cloud_controller/config_schemas/deployment_updater_schema.rb index 11814cea8f0..1ac89aa044a 100644 --- a/lib/cloud_controller/config_schemas/deployment_updater_schema.rb +++ b/lib/cloud_controller/config_schemas/deployment_updater_schema.rb @@ -6,6 +6,8 @@ class DeploymentUpdaterSchema < VCAP::Config # rubocop:disable Metrics/BlockLength define_schema do { + optional(:service_accounts_enabled) => bool, + optional(:service_account_token_endpoint) => String, logging: { level: String, # debug, info, etc. diff --git a/lib/cloud_controller/config_schemas/worker_schema.rb b/lib/cloud_controller/config_schemas/worker_schema.rb index e095e79fe74..8a47b2960e3 100644 --- a/lib/cloud_controller/config_schemas/worker_schema.rb +++ b/lib/cloud_controller/config_schemas/worker_schema.rb @@ -35,8 +35,7 @@ class WorkerSchema < VCAP::Config identity_ca_ref: String, optional(:allowed_resources_ref) => String }, - optional(:service_account_runtime_enabled) => bool, - optional(:service_account_runtime_contract_version) => Integer, + optional(:service_accounts_enabled) => bool, optional(:service_account_token_endpoint) => String, logging: { diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb index 9106ac215a8..d6721429da0 100644 --- a/lib/cloud_controller/diego/service_account_identity.rb +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -43,14 +43,11 @@ def valid_endpoint?(endpoint) def ready_account return unless @account_guid - unless @config.get(:service_account_runtime_enabled) == true + unless @config.get(:service_accounts_enabled) == true raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account runtime is not enabled') end account = ServiceAccountModel.first(guid: @account_guid) - if @config.get(:service_account_runtime_contract_version) != 2 - raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account qualified runtime contract version 2 is not enabled') - end unless account && account.enabled && account.status == 'ready' && account.entitled_to?(@app.space) raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account is not ready for runtime credentials') end From a5eb5e9dad205d543218024708164bec002784a0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 20:06:16 +0100 Subject: [PATCH 191/200] Test disabled account reconciliation preserves shared domain freshness (red) --- spec/isolated_specs/processes_sync_spec.rb | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/spec/isolated_specs/processes_sync_spec.rb b/spec/isolated_specs/processes_sync_spec.rb index 2ed21bbb9bf..ddac68bbcb5 100644 --- a/spec/isolated_specs/processes_sync_spec.rb +++ b/spec/isolated_specs/processes_sync_spec.rb @@ -31,6 +31,48 @@ module Diego expect(bbs_apps_client).to have_received(:bump_freshness).once end + context 'when a missing LRP belongs to a disabled service account' do + let(:config) { TestConfig.config_instance } + let(:bbs_transport) { double(:bbs_transport) } + let(:bbs_apps_client) { BbsAppsClient.new(bbs_transport, config) } + let!(:blocked_process) { create(:process_model, :diego_runnable, app: create(:app_model, space: create(:space, :with_service_account_prefix))) } + let!(:healthy_process) { create(:process_model, :diego_runnable) } + + before do + TestConfig.override(service_accounts_enabled: true, service_account_token_endpoint: 'https://uaa.example.test/oauth/mtls/token') + CloudController::DependencyLocator.instance.register(:bbs_apps_client, bbs_apps_client) + account = ServiceAccountModel.create(name: 'blocked-worker', space: blocked_process.space, status: 'ready') + blocked_process.app.update(service_account: account) + blocked_process.update(service_account_guid: account.guid, service_account_snapshot: true) + account.update(enabled: false) + allow(bbs_transport).to receive(:desired_lrp_scheduling_infos).and_return( + ::Diego::Bbs::Models::DesiredLRPSchedulingInfosResponse.new + ) + allow(bbs_transport).to receive(:desire_lrp).and_return(::Diego::Bbs::Models::DesiredLRPResponse.new) + allow(bbs_transport).to receive(:upsert_domain).and_return(::Diego::Bbs::Models::UpsertDomainResponse.new) + allow(bbs_apps_client).to receive(:desire_app).and_call_original + allow(bbs_apps_client).to receive(:bump_freshness).and_call_original + allow(AppRecipeBuilder).to receive(:new) do |config:, process:| + recipe = double(:identity_recipe) + allow(recipe).to receive(:build_app_lrp) do + properties = ServiceAccountIdentity.new(process.app, config, account_guid: process.runtime_service_account_guid).certificate_properties + ::Diego::Bbs::Models::DesiredLRP.new(process_guid: ProcessGuid.from_process(process), certificate_properties: properties) + end + recipe + end + end + + it 'refuses its new credentials while reconciling healthy apps and refreshing the shared domain', isolation: :truncation do + subject.sync + + expect(bbs_transport).to have_received(:desire_lrp).once.with( + have_attributes(process_guid: ProcessGuid.from_process(healthy_process)) + ) + expect(bbs_transport).to have_received(:upsert_domain).with(domain: APP_LRP_DOMAIN, ttl: APP_LRP_DOMAIN_TTL) + expect(statsd_updater).to have_received(:update_synced_invalid_lrps).with(1) + end + end + context 'when bbs and CC are in sync' do let(:scheduling_infos) { [good_lrp_scheduling_info] } let(:good_lrp_scheduling_info) do From a35d32b6324385c4332159872c1f1d69cf2fb725 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 20:11:40 +0100 Subject: [PATCH 192/200] Pin enabled runtime config in disabled account sync regression (red) --- spec/isolated_specs/processes_sync_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/isolated_specs/processes_sync_spec.rb b/spec/isolated_specs/processes_sync_spec.rb index ddac68bbcb5..6ef7a6ff999 100644 --- a/spec/isolated_specs/processes_sync_spec.rb +++ b/spec/isolated_specs/processes_sync_spec.rb @@ -32,7 +32,7 @@ module Diego end context 'when a missing LRP belongs to a disabled service account' do - let(:config) { TestConfig.config_instance } + let(:config) { Config.new({ service_accounts_enabled: true }) } let(:bbs_transport) { double(:bbs_transport) } let(:bbs_apps_client) { BbsAppsClient.new(bbs_transport, config) } let!(:blocked_process) { create(:process_model, :diego_runnable, app: create(:app_model, space: create(:space, :with_service_account_prefix))) } From 405a06262c44ba82ba48c311d74bfd70140d40a5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 20:18:18 +0100 Subject: [PATCH 193/200] Keep process sync freshness on expected account readiness rejection --- lib/cloud_controller/diego/processes_sync.rb | 3 ++- lib/cloud_controller/diego/service_account_identity.rb | 4 +++- spec/isolated_specs/processes_sync_spec.rb | 8 ++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/cloud_controller/diego/processes_sync.rb b/lib/cloud_controller/diego/processes_sync.rb index eadc8d20b00..8d6847f539d 100644 --- a/lib/cloud_controller/diego/processes_sync.rb +++ b/lib/cloud_controller/diego/processes_sync.rb @@ -1,4 +1,5 @@ require 'utils/workpool' +require 'cloud_controller/diego/service_account_identity' module VCAP::CloudController module Diego @@ -69,7 +70,7 @@ def process_workpool_exceptions(exceptions) invalid_lrps = 0 exceptions.each do |e| error_name = e.is_a?(CloudController::Errors::ApiError) ? e.name : e.class.name - if error_name == 'RunnerInvalidRequest' + if error_name == 'RunnerInvalidRequest' || e.is_a?(ServiceAccountIdentity::NotReady) logger.info('synced-invalid-desired-lrps', error: error_name, error_message: e.message) invalid_lrps += 1 elsif error_name == 'RunnerError' && e.message['the requested resource already exists'] diff --git a/lib/cloud_controller/diego/service_account_identity.rb b/lib/cloud_controller/diego/service_account_identity.rb index d6721429da0..afabff5eda8 100644 --- a/lib/cloud_controller/diego/service_account_identity.rb +++ b/lib/cloud_controller/diego/service_account_identity.rb @@ -3,6 +3,8 @@ module VCAP::CloudController module Diego class ServiceAccountIdentity + class NotReady < CloudController::Errors::ApiError; end + def initialize(app, config, account_guid: app.service_account_guid) @app = app @config = config @@ -49,7 +51,7 @@ def ready_account account = ServiceAccountModel.first(guid: @account_guid) unless account && account.enabled && account.status == 'ready' && account.entitled_to?(@app.space) - raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'Service account is not ready for runtime credentials') + raise NotReady.new_from_details('UnprocessableEntity', 'Service account is not ready for runtime credentials') end account diff --git a/spec/isolated_specs/processes_sync_spec.rb b/spec/isolated_specs/processes_sync_spec.rb index 6ef7a6ff999..d50ee977552 100644 --- a/spec/isolated_specs/processes_sync_spec.rb +++ b/spec/isolated_specs/processes_sync_spec.rb @@ -45,11 +45,11 @@ module Diego blocked_process.app.update(service_account: account) blocked_process.update(service_account_guid: account.guid, service_account_snapshot: true) account.update(enabled: false) - allow(bbs_transport).to receive(:desired_lrp_scheduling_infos).and_return( - ::Diego::Bbs::Models::DesiredLRPSchedulingInfosResponse.new + allow(bbs_transport).to receive_messages( + desired_lrp_scheduling_infos: ::Diego::Bbs::Models::DesiredLRPSchedulingInfosResponse.new, + desire_lrp: ::Diego::Bbs::Models::DesiredLRPResponse.new, + upsert_domain: ::Diego::Bbs::Models::UpsertDomainResponse.new ) - allow(bbs_transport).to receive(:desire_lrp).and_return(::Diego::Bbs::Models::DesiredLRPResponse.new) - allow(bbs_transport).to receive(:upsert_domain).and_return(::Diego::Bbs::Models::UpsertDomainResponse.new) allow(bbs_apps_client).to receive(:desire_app).and_call_original allow(bbs_apps_client).to receive(:bump_freshness).and_call_original allow(AppRecipeBuilder).to receive(:new) do |config:, process:| From 0aa5d107903555204dbca6fddb1667a3a965fd5a Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 20:42:55 +0100 Subject: [PATCH 194/200] Test accepted GUID and timestamp filters on service account lists (red) --- spec/request/service_accounts_spec.rb | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/spec/request/service_accounts_spec.rb b/spec/request/service_accounts_spec.rb index 06eaf8e58e1..a255f0b5fd0 100644 --- a/spec/request/service_accounts_spec.rb +++ b/spec/request/service_accounts_spec.rb @@ -223,6 +223,56 @@ def headers(role) expect(Oj.load(last_response.body)['resources']).to eq([]) end + context 'common list filters' do + let!(:earlier_account) { VCAP::CloudController::ServiceAccountModel.create(name: 'earlier-worker', space: space) } + let!(:later_account) { VCAP::CloudController::ServiceAccountModel.create(name: 'later-worker', space: space) } + + before do + earlier_account.this.update(created_at: Time.utc(2026, 1, 1), updated_at: Time.utc(2026, 1, 1)) + later_account.this.update(created_at: Time.utc(2026, 1, 3), updated_at: Time.utc(2026, 1, 3)) + end + + it 'filters by GUID before pagination' do + get "/v3/service_accounts?guids=#{later_account.guid}&per_page=1", nil, headers('space_developer') + + expect(last_response.status).to eq(200) + result = Oj.load(last_response.body) + expect(result['resources'].pluck('guid')).to eq([later_account.guid]) + expect(result.dig('pagination', 'total_results')).to eq(1) + end + + it 'filters creation timestamps' do + get '/v3/service_accounts?created_ats[lt]=2026-01-02T00:00:00Z', nil, headers('space_developer') + + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['resources'].pluck('guid')).to eq([earlier_account.guid]) + end + + it 'filters update timestamps independently of creation time' do + earlier_account.this.update(updated_at: Time.utc(2026, 1, 4)) + + get '/v3/service_accounts?updated_ats[gte]=2026-01-04T00:00:00Z', nil, headers('space_developer') + + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['resources'].pluck('guid')).to eq([earlier_account.guid]) + end + + it 'intersects GUIDs with visibility and account-specific filters' do + hidden = VCAP::CloudController::ServiceAccountModel.create(name: 'hidden-worker', space: create(:space, :with_service_account_prefix)) + query = "guids=#{earlier_account.guid},#{hidden.guid}&organization_guids=#{org.guid}&space_guids=#{space.guid}&names=earlier-worker" + + get "/v3/service_accounts?#{query}", nil, headers('space_developer') + + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['resources'].pluck('guid')).to eq([earlier_account.guid]) + + get "/v3/service_accounts?guids=#{hidden.guid}", nil, headers('space_developer') + + expect(last_response.status).to eq(200) + expect(Oj.load(last_response.body)['resources']).to be_empty + end + end + it 'loads presentation associations once per page rather than once per account' do auth = headers('admin') accounts = Array.new(3) do |index| From 1bf0e29de0252238f0b65e98c418618af134095f Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 12 Nov 2026 20:50:05 +0100 Subject: [PATCH 195/200] Apply common GUID and date filters to service account lists --- app/controllers/v3/service_accounts_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/v3/service_accounts_controller.rb b/app/controllers/v3/service_accounts_controller.rb index 3c1b976955c..d49bfac1d8f 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 'fetchers/app_list_fetcher' +require 'fetchers/base_list_fetcher' require 'presenters/v3/app_presenter' require 'repositories/service_account_event_repository' require 'actions/service_account_creation_budget' @@ -23,6 +24,7 @@ def index dataset = dataset.where(guid: ServiceAccountModel.db[:service_account_space_entitlements].where(space_guid: message.space_guids).select(:service_account_guid)) end dataset = dataset.where(organization_id: Organization.where(guid: message.organization_guids).select(:id)) if message.requested?(:organization_guids) + dataset = BaseListFetcher.filter(message, dataset, ServiceAccountModel) render_list(dataset, message, Presenters::V3::ServiceAccountPresenter, '/v3/service_accounts') end From 58a04f18d4bdab2f00d4e99c2a0350106e1555b1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 13 Nov 2026 16:04:18 +0100 Subject: [PATCH 196/200] Test space-first entitlement lookup index (red) --- spec/migrations/service_account_schema_spec.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/migrations/service_account_schema_spec.rb b/spec/migrations/service_account_schema_spec.rb index adf725333d0..c798dce9fb8 100644 --- a/spec/migrations/service_account_schema_spec.rb +++ b/spec/migrations/service_account_schema_spec.rb @@ -12,5 +12,8 @@ expect(columns.fetch(:organization_id)[:allow_null]).to be(false) expect(columns.fetch(:identity_prefix)[:allow_null]).to be(false) expect(db.schema(:service_account_names).to_h).to have_key(:deleted_at) + expect(db.indexes(:service_account_space_entitlements).values).to include( + hash_including(columns: %i[space_guid service_account_guid]) + ) end end From f2903b38b558c2ed3c6c5a7c6be8744a07b859e0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 13 Nov 2026 16:04:42 +0100 Subject: [PATCH 197/200] Index entitlement lookups by space and account --- .../20261109121000_create_service_account_space_entitlements.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/db/migrations/20261109121000_create_service_account_space_entitlements.rb b/db/migrations/20261109121000_create_service_account_space_entitlements.rb index 2f567e8684c..febbaa5f9ef 100644 --- a/db/migrations/20261109121000_create_service_account_space_entitlements.rb +++ b/db/migrations/20261109121000_create_service_account_space_entitlements.rb @@ -13,6 +13,7 @@ String :service_account_guid, **account_guid_options, size: 255, null: false String :space_guid, **space_guid_options, size: 255, null: false primary_key %i[service_account_guid space_guid], name: :service_account_space_entitlements_pkey + index %i[space_guid service_account_guid], name: :service_account_entitlements_space_account_index, concurrently: false foreign_key [:service_account_guid], :service_accounts, key: :guid, name: :fk_sa_entitlements_account, on_delete: :cascade foreign_key [:space_guid], :spaces, key: :guid, name: :fk_sa_entitlements_space, on_delete: :cascade end From 2f0f042e4f27053c88afc71636b5cfebb48eb45e Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 13 Nov 2026 16:05:08 +0100 Subject: [PATCH 198/200] Test shared issuer discovery reuse expiry and failure handling (red) --- .../uaa/issuer_discovery_spec.rb | 51 +++++++++++++++++++ .../service_brokers/v2/client_spec.rb | 16 ++++++ 2 files changed, 67 insertions(+) create mode 100644 spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb diff --git a/spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb b/spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb new file mode 100644 index 00000000000..1b8e6745477 --- /dev/null +++ b/spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe 'Shared UAA issuer discovery' do + let(:locator) { CloudController::DependencyLocator.instance } + let(:discovery_url) { 'https://uaa.service.cf.internal/.well-known/openid-configuration' } + + it 'reuses successful discovery across callers until the cache expires' do + request = stub_request(:get, discovery_url).to_return(body: { issuer: 'https://uaa.example.test/oauth/token' }.to_json) + + Timecop.freeze do + first = locator.uaa_issuer_discovery + expect(first.issuer).to eq('https://uaa.example.test/oauth/token') + expect(locator.uaa_issuer_discovery.issuer).to eq('https://uaa.example.test/oauth/token') + expect(request).to have_been_requested.once + Timecop.travel(Time.now + 301) + expect(first.issuer).to eq('https://uaa.example.test/oauth/token') + expect(request).to have_been_requested.twice + end + end + + it 'does not cache unsuccessful discovery and permits retry' do + request = stub_request(:get, discovery_url).to_return(status: 503).then.to_return(body: { issuer: 'https://uaa.example.test/oauth/token' }.to_json) + + expect { locator.uaa_issuer_discovery.issuer }.to raise_error(/issuer/) + expect(locator.uaa_issuer_discovery.issuer).to eq('https://uaa.example.test/oauth/token') + expect(request).to have_been_requested.twice + end + + it 'shares one successful lookup among concurrent callers' do + request = stub_request(:get, discovery_url).to_return(body: { issuer: 'https://uaa.example.test/oauth/token' }.to_json) + discovery = locator.uaa_issuer_discovery + gate = Queue.new + threads = Array.new(4) { Thread.new { gate.pop; discovery.issuer } } + threads.size.times { gate << true } + + expect(threads.map(&:value)).to eq(Array.new(4, 'https://uaa.example.test/oauth/token')) + expect(request).to have_been_requested.once + end + + it 'fails closed instead of using an expired issuer when refresh fails' do + stub_request(:get, discovery_url).to_return(body: { issuer: 'https://uaa.example.test/oauth/token' }.to_json).then.to_return(status: 503) + + Timecop.freeze do + expect(locator.uaa_issuer_discovery.issuer).to eq('https://uaa.example.test/oauth/token') + Timecop.travel(Time.now + 301) + expect { locator.uaa_issuer_discovery.issuer }.to raise_error(/issuer/) + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/client_spec.rb b/spec/unit/lib/services/service_brokers/v2/client_spec.rb index 1e2f2b81897..12cd62fcdfa 100644 --- a/spec/unit/lib/services/service_brokers/v2/client_spec.rb +++ b/spec/unit/lib/services/service_brokers/v2/client_spec.rb @@ -1442,6 +1442,7 @@ module VCAP::Services::ServiceBrokers::V2 app.space.organization.update(service_account_prefix: 'broker-team') account = VCAP::CloudController::ServiceAccountModel.create(name: 'broker-worker', space: app.space, status: 'ready') app.update(service_account: account) + stub_request(:get, 'https://uaa.service.cf.internal/.well-known/openid-configuration').to_return(body: { issuer: UAAIssuer::ISSUER }.to_json) client.bind(binding) @@ -1449,6 +1450,21 @@ module VCAP::Services::ServiceBrokers::V2 expect(http_client).to have_received(:put).with(anything, hash_including(context: context), anything) end + it 'uses shared issuer discovery across broker client instances' do + app.space.organization.update(service_account_prefix: 'broker-team') + account = VCAP::CloudController::ServiceAccountModel.create(name: 'broker-worker', space: app.space, status: 'ready') + app.update(service_account: account) + discovery = double(:issuer_discovery, issuer: 'https://shared-issuer.example.test/oauth/token') + allow(CloudController::DependencyLocator.instance).to receive(:uaa_issuer_discovery).and_return(discovery) + + client.bind(binding) + described_class.new(client_attrs).bind(binding) + + expect(discovery).to have_received(:issuer).twice + expected_context = hash_including(service_account: { issuer: 'https://shared-issuer.example.test/oauth/token', subject: account.client_id }) + expect(http_client).to have_received(:put).with(anything, hash_including(context: expected_context), anything).twice + end + it 'fails closed rather than sending a credential-only bind when its captured account is missing' do binding.update(service_account_guid: SecureRandom.uuid) expect { client.bind(binding) }.to raise_error(VCAP::CloudController::ServiceAccountProvision::Conflict, /not found/) From e4a087a9b5acd56edeaacbaba92d046931e013b9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 13 Nov 2026 16:05:31 +0100 Subject: [PATCH 199/200] Share synchronized UAA issuer discovery across broker binds --- lib/cloud_controller/dependency_locator.rb | 5 ++++ lib/cloud_controller/uaa/issuer_discovery.rb | 31 ++++++++++++++++++++ lib/services/service_brokers/v2/client.rb | 2 +- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 lib/cloud_controller/uaa/issuer_discovery.rb diff --git a/lib/cloud_controller/dependency_locator.rb b/lib/cloud_controller/dependency_locator.rb index 3da652a1937..06f21b7e14f 100644 --- a/lib/cloud_controller/dependency_locator.rb +++ b/lib/cloud_controller/dependency_locator.rb @@ -27,6 +27,7 @@ require 'statsd/instrument' require 'cloud_controller/execution_context' require 'cloud_controller/uaa/service_account_client' +require 'cloud_controller/uaa/issuer_discovery' require 'actions/service_account_provision' module CloudController @@ -327,6 +328,10 @@ def service_account_provisioner ServiceAccountProvision.new(clients, identity_ca_ref: settings[:identity_ca_ref], allowed_resources_ref: settings[:allowed_resources_ref])) end + def uaa_issuer_discovery + @dependencies[:uaa_issuer_discovery] ||= IssuerDiscovery.new(config.get(:uaa)) + end + def uaa_shadow_user_creation_client client = config.get(:uaa, :clients)&.find { |client_config| client_config['name'] == 'cloud_controller_shadow_user_creation' } diff --git a/lib/cloud_controller/uaa/issuer_discovery.rb b/lib/cloud_controller/uaa/issuer_discovery.rb new file mode 100644 index 00000000000..cdaa9577d5a --- /dev/null +++ b/lib/cloud_controller/uaa/issuer_discovery.rb @@ -0,0 +1,31 @@ +require 'httpclient' + +module VCAP::CloudController + class IssuerDiscovery + CACHE_TTL = 300 + + def initialize(config) + @config = config + @lock = Mutex.new + end + + def issuer + @lock.synchronize do + return @issuer if @expires_at && Time.now < @expires_at + + client = HTTPClient.new(base_url: @config[:internal_url]) + client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_PEER + client.ssl_config.set_trust_ca(@config[:ca_file]) if @config[:ca_file].present? + response = client.get('.well-known/openid-configuration') + raise "Could not retrieve issuer information from UAA: #{response.status}" unless response.status == 200 + + value = Oj.load(response.body).fetch('issuer') + raise 'UAA issuer discovery returned an empty issuer' unless value.is_a?(String) && value.present? + + @issuer = value + @expires_at = Time.now + CACHE_TTL + @issuer + end + end + end +end diff --git a/lib/services/service_brokers/v2/client.rb b/lib/services/service_brokers/v2/client.rb index 9184b75dfd2..9ad8e55fd66 100644 --- a/lib/services/service_brokers/v2/client.rb +++ b/lib/services/service_brokers/v2/client.rb @@ -379,7 +379,7 @@ def add_service_account_context(body, binding) raise VCAP::CloudController::ServiceAccountProvision::Conflict.new('service account is not ready for a broker binding') end - issuer = VCAP::CloudController::UaaTokenDecoder.new(VCAP::CloudController::Config.config.get(:uaa)).send(:uaa_issuer) + issuer = CloudController::DependencyLocator.instance.uaa_issuer_discovery.issuer body[:context][:service_account] = { issuer: issuer, subject: account.client_id } end From 864cb3bebcf782cac9d1dfbf42fef95f12ea4a80 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 13 Nov 2026 16:36:28 +0100 Subject: [PATCH 200/200] Parse issuer discovery strictly and fix concurrent spec formatting --- lib/cloud_controller/uaa/issuer_discovery.rb | 2 +- .../cloud_controller/uaa/issuer_discovery_spec.rb | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/cloud_controller/uaa/issuer_discovery.rb b/lib/cloud_controller/uaa/issuer_discovery.rb index cdaa9577d5a..d15a2786ce8 100644 --- a/lib/cloud_controller/uaa/issuer_discovery.rb +++ b/lib/cloud_controller/uaa/issuer_discovery.rb @@ -19,7 +19,7 @@ def issuer response = client.get('.well-known/openid-configuration') raise "Could not retrieve issuer information from UAA: #{response.status}" unless response.status == 200 - value = Oj.load(response.body).fetch('issuer') + value = Oj.strict_load(response.body).fetch('issuer') raise 'UAA issuer discovery returned an empty issuer' unless value.is_a?(String) && value.present? @issuer = value diff --git a/spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb b/spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb index 1b8e6745477..11bdf420ba4 100644 --- a/spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb +++ b/spec/unit/lib/cloud_controller/uaa/issuer_discovery_spec.rb @@ -31,13 +31,24 @@ module VCAP::CloudController request = stub_request(:get, discovery_url).to_return(body: { issuer: 'https://uaa.example.test/oauth/token' }.to_json) discovery = locator.uaa_issuer_discovery gate = Queue.new - threads = Array.new(4) { Thread.new { gate.pop; discovery.issuer } } + threads = Array.new(4) do + Thread.new do + gate.pop + discovery.issuer + end + end threads.size.times { gate << true } expect(threads.map(&:value)).to eq(Array.new(4, 'https://uaa.example.test/oauth/token')) expect(request).to have_been_requested.once end + it 'treats Ruby object markers as ordinary JSON data' do + stub_request(:get, discovery_url).to_return(body: '{"^o":"Object","issuer":"https://uaa.example.test/oauth/token"}') + + expect(locator.uaa_issuer_discovery.issuer).to eq('https://uaa.example.test/oauth/token') + end + it 'fails closed instead of using an expired issuer when refresh fails' do stub_request(:get, discovery_url).to_return(body: { issuer: 'https://uaa.example.test/oauth/token' }.to_json).then.to_return(status: 503)