Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
637c070
Test space-owned service accounts and same-space assignment (red)
rkoster Oct 5, 2026
928575b
Implement space-owned service accounts and same-space assignment
rkoster Oct 5, 2026
1a66aa1
Test same-space app account relationship endpoint (red)
rkoster Oct 5, 2026
34dd07a
Expose same-space app service account relationship
rkoster Oct 5, 2026
999a6cd
Test account client and OAuth principal provisioning (red)
rkoster Oct 5, 2026
3c9e1c7
Reconcile account clients and roleless OAuth principals
rkoster Oct 5, 2026
fa49879
Test provisioning HTTP retries and altered TLS policies (red)
rkoster Oct 5, 2026
702fb37
Normalize managed client sets and reject extra TLS policies
rkoster Oct 5, 2026
d465593
Test retryable account provisioning worker job (red)
rkoster Oct 5, 2026
938c5cf
Add credential-free retryable account provisioning job
rkoster Oct 5, 2026
8bd6cb7
Test configured provisioning client and token refresh (red)
rkoster Oct 5, 2026
9092f44
Configure dedicated account provisioning client in workers
rkoster Oct 5, 2026
270ccf4
Test asynchronous first-bind provisioning and job reuse (red)
rkoster Oct 5, 2026
89673d7
Queue pollable provisioning on authorized first bind
rkoster Oct 5, 2026
4e64e51
Test account listing description and metadata updates (red)
rkoster Oct 5, 2026
4453c3c
Add scoped account listing description and metadata APIs
rkoster Oct 5, 2026
76bddca
Test account disable enable and safe deletion lifecycle (red)
rkoster Oct 5, 2026
9d62b7b
Reconcile account disable enable and unused-account deletion
rkoster Oct 5, 2026
69b9638
Test service account and assignment audit events (red)
rkoster Oct 5, 2026
e3d62b8
Audit service account lifecycle and assignment changes
rkoster Oct 5, 2026
3f59b0d
Test explicit account roles without human UAA lookup (red)
rkoster Oct 5, 2026
b520f3a
Resolve managed account names locally for explicit role APIs
rkoster Oct 5, 2026
b3fbf77
Test typed runtime identity and fail-closed account gates (red)
rkoster Oct 5, 2026
7b491b0
Build typed ready-account runtime identity and discovery metadata
rkoster Oct 5, 2026
d1a07f4
Test runtime and task account propagation boundaries (red)
rkoster Oct 5, 2026
570e417
Propagate account identity to runtime recipes and discovery
rkoster Oct 5, 2026
527812f
Test task account identity snapshot at creation (red)
rkoster Oct 5, 2026
6c56629
Snapshot task account identity before asynchronous submission
rkoster Oct 5, 2026
820fc87
Test process account launch snapshot on restart (red)
rkoster Oct 5, 2026
6172393
Preserve process account launch identity until explicit restart
rkoster Oct 5, 2026
bbef6e4
Test staging exclusion of injected account discovery (red)
rkoster Oct 5, 2026
87b3b82
Exclude service account discovery from all staging inputs
rkoster Oct 5, 2026
c40ff32
Test active identity deletion and secretless discovery guards (red)
rkoster Oct 5, 2026
5b59bd4
Protect active launch identities and non-secret endpoint discovery
rkoster Oct 5, 2026
e4e4368
Verify pollable account provisioning retries through completion
rkoster Oct 5, 2026
30a7268
Verify concurrent apps share one first-bind provisioning job
rkoster Oct 5, 2026
7435e10
Test provisioning validation for API local workers (red)
rkoster Oct 5, 2026
dc3d3a0
Validate optional provisioning inputs for API local workers
rkoster Oct 5, 2026
b74c294
Test pre-feature launches cannot acquire new account identity (red)
rkoster Oct 5, 2026
7c7c97b
Keep pre-feature launches account-free until explicit launch
rkoster Oct 5, 2026
b9fc5c1
Test account app listing applies requested filters (red)
rkoster Oct 5, 2026
b8d8187
Apply normal app filters to account app listing
rkoster Oct 5, 2026
468201e
Test bind exclusion during retrying lifecycle operations (red)
rkoster Oct 5, 2026
da5b96b
Block binding during retrying disable or delete operations
rkoster Oct 5, 2026
2c9e34c
Test owned account guard before recursive space deletion (red)
rkoster Oct 5, 2026
a19c292
Report owned service accounts before recursive space deletion
rkoster Oct 5, 2026
aab9c77
Verify rebuilt LRPs preserve launch account after unbind
rkoster Oct 5, 2026
8af06c4
Document experimental space-owned service account API and rollout
rkoster Oct 5, 2026
27549c8
Test MySQL account relationship collation on migration retry (red)
rkoster Oct 6, 2026
e4fdc2b
Match parent MySQL collation and retry partial account assignment mig…
rkoster Oct 6, 2026
4188ec6
Test UAA normalized inert scope during account reconciliation (red)
rkoster Oct 6, 2026
8a4e504
Accept UAA inert scope normalization for managed client reconciliation
rkoster Oct 6, 2026
93fffd2
Test rolling service account creation budget and admin exemption (red)
rkoster Oct 7, 2026
a1a6dc0
Enforce atomic weekly service account creation budget for non-admins
rkoster Oct 7, 2026
052cd62
Test cascading account cleanup on space and org deletion (red)
rkoster Oct 7, 2026
c3c53df
Correct task fixture and confirm assigned workload cascade failure (red)
rkoster Oct 7, 2026
ec582b7
Cascade owned service account cleanup during space and org deletion
rkoster Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions app/actions/app_assign_service_account.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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, actor: nil)
@permissions = permissions
@actor = actor
end

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)

if account
account.lock!
raise Conflict.new('account must belong to the app owning space') unless account.space_guid == app.space_guid

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

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)

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)
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
2 changes: 1 addition & 1 deletion app/actions/process_restart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions app/actions/service_account_creation_budget.rb
Original file line number Diff line number Diff line change
@@ -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
112 changes: 112 additions & 0 deletions app/actions/service_account_provision.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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 matching_registration?(existing, desired)
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

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 or in use') if delete && account.in_use?

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)
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)
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
actual == value
end
end
end

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
37 changes: 36 additions & 1 deletion app/actions/space_delete.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,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
Expand Down Expand Up @@ -76,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)
Expand Down
2 changes: 2 additions & 0 deletions app/actions/task_create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 5 additions & 3 deletions app/collection_transformers/username_populator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
43 changes: 43 additions & 0 deletions app/controllers/v3/app_service_accounts_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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
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
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)

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