Learn: Self-Service Cloud Resources — orientation
How a developer gets a real S3 bucket, SQS queue, SNS topic, or DynamoDB table without filing a ticket, waiting on the platform team, or writing a line of IAM — and how the platform hands it over safe by construction and least-privilege every time. It turns “I need a bucket” from a week-long request into a few lines in a file you already own.
This one is developer-first: if you build products on the platform, this is your self-service. It builds on
the Environment API — these resources are a field on your XEnvironment
claim — and on Identity & Access, which is how your pod gets AWS credentials
through Pod Identity.
The question
Section titled “The question”Your service needs to store uploads (a bucket), buffer background work (a queue), fan out events (a topic),
keep sessions (a table). The traditional path is grim: file a ticket, wait, and eventually someone hands you
a bucket — plus you now have to write an IAM policy to let your app reach it, which either takes hours to get
right or, far more often, ends up as s3:* on * because that’s what makes the error go away.
So how do you get real cloud resources on demand, wired to your app, without becoming an IAM expert or a security liability — and how does the platform keep “self-service” from meaning “self-inflicted breach”?
The one idea: declare intent above the claim; the platform derives safety below it
Section titled “The one idea: declare intent above the claim; the platform derives safety below it”This is the model the paved road is built around:
Abstraction lives above the claim; safety lives below it. You declare the resource you want as abstract intent in your Environment claim — what, not how. The platform provisions it safe-by-construction and derives the exact least-privilege IAM to reach it. You never write IAM; you never harden a bucket; you never see an access key.
That split is the trick. Above the claim, the front door can be anything — a Backstage form, a pull request, a natural-language agent — because they all produce the same governed claim. Below the claim, one safe pipeline validates and realizes it. Make the front door as magical as you like; the safety floor never moves.
Think of a restaurant order ticket. You write “medium-rare, no onions” — your intent. You don’t enter the kitchen, own the knives, or plate the dish; the kitchen turns your ticket into the meal and hands you exactly the right cutlery. Here, your claim is the ticket, the platform is the kitchen, and the “cutlery” is the precisely-scoped IAM it hands your pod. Where it breaks: a kitchen trusts you to order sensibly; this one also validates the ticket against a safety floor and refuses anything unsafe — you can’t order “raw chicken with root access.”
Here it is on a real service.
The worked example — four resources, one claim
Section titled “The worked example — four resources, one claim”Here’s the bravo/dispatch shipments service asking for all four resource types, in its Environment claim
(gitops/environments/bravo/dispatch/dev.yaml) — the entire ask:
kind: XEnvironmentspec: team: bravo product: dispatch stage: dev services: shipments: serviceAccount: app-bravo-shipments resources: blob: { kind: objectstore, engine: s3, access: readwrite } requests: { kind: stream, engine: sqs, access: readwrite } events: { kind: stream, engine: sns, access: readwrite } shipments: { kind: keyvalue, engine: dynamodb, access: readwrite }What the developer actually wrote: a logical name (blob, requests, …), an abstract kind (objectstore,
stream, keyvalue), a concrete engine, and an access level. No ARNs. No IAM. No encryption settings. No
bucket policy. That’s the whole intent.
And here’s what’s live on preprod right now — all four provisioned and ready, at steady state, not a throwaway demo:
# one comma-joined, fully-qualified arg — the short form (`bucket.s3`) makes kubectl# read the rest as resource *names* and fail; see the Reference for why.$ kubectl -n bravo-dispatch-dev get \ bucket.s3.aws.m.upbound.io,queue.sqs.aws.m.upbound.io,topic.sns.aws.m.upbound.io,table.dynamodb.aws.m.upbound.ioNAME SYNCED READY EXTERNAL-NAME…/bravo-dispatch-dev-…(bucket) True True refplat-bravo-dispatch-dev-blob-7d258453…/bravo-dispatch-dev-…(queue) True True https://sqs.us-east-1.amazonaws.com/<workload-acct>/refplat-bravo-dispatch-dev-requests-d5c2d549…/bravo-dispatch-dev-…(topic) True True refplat-bravo-dispatch-dev-events-51b1a7ce…/bravo-dispatch-dev-…(table) True True refplat-bravo-dispatch-dev-shipments-982701e1# (kubectl prints one table per kind; collapsed here. A trailing AGE column is elided.)Four real AWS resources, from four lines of intent. But the resources are only half of it — the reach is the other half. Here’s the fan-out from that one claim:
One line of intent became a hardened resource, an IAM policy scoped to exactly it, and the wiring for your app to find it — all derived, none hand-written.
Above the claim — abstract intent
Section titled “Above the claim — abstract intent”The developer said kind: objectstore, not “an S3 bucket with these settings.” The claim is deliberately
abstract:
kind— the class of thing:objectstore,stream,keyvalue(and, in the schema,relational/cache). This is what you actually care about.engine— the concrete implementation (s3,sqs,sns,dynamodb). All four are built and live today.access—readorreadwrite. Your intent, not a list of API actions.
Because the abstraction sits above the claim, the platform can later swap or add engines — a different queue
technology, a new database — without churning your claim. You asked for stream/readwrite, and that stays
true whatever backs it.
Below the claim — safety you didn’t have to know about
Section titled “Below the claim — safety you didn’t have to know about”This is where “self-service” stops being scary. Everything below the claim is safe-by-construction and non-overridable — you can’t get it wrong because you don’t configure it at all:
-
The resource is hardened by default. For the S3 bucket: public access blocked, TLS-only (a bucket policy denies any non-HTTPS request), versioning on, region pinned, and encrypted — every engine uses service-managed encryption (SSE-S3 / SSE-SQS / SNS
alias/aws/sns/ DynamoDB default). (Per-team KMS CMKs for regulated tiers — cryptographic tenancy isolation — are designed but not yet built; the Composition comments mark that path deferred.) You didn’t ask for any of that; you can’t turn it off. -
The IAM is derived, least-privilege, and resource-scoped. This is the part that usually goes wrong by hand, and here you never touch it. From
access: readwrite, the platform generates an IAM policy scoped to exactly this one resource’s ARN, with exactly the verbs that level implies:engine readgrantsreadwriteaddsS3 GetObject,ListBucket,GetBucketLocationPutObject,DeleteObject,AbortMultipartUploadSQS ReceiveMessage,DeleteMessage,GetQueueAttributes, …SendMessageSNS GetTopicAttributes,Subscribe,ListSubscriptionsByTopicPublishThat policy is injected straight into your service’s Pod-Identity role (see Identity & Access) — so your pod can reach its bucket and nothing else. No
s3:*, no*resource, no shared credentials. The lazy path and the least-privilege path are the same path.
This is the whole security case for the paved road. Hand-written IAM is where least-privilege goes to die —
under a deadline, s3:* on * is what makes the red error disappear, and it quietly hands a pod the keys to
every bucket in the account. Then the day that pod is compromised — a bad dependency, an SSRF, a
deserialization bug — its blast radius is everything that policy allowed, which is how an over-broad grant
turns a small bug into a lateral-movement launchpad. Derived IAM collapses that: every pod can reach exactly
its own resources and nothing else, by construction, with no tired human in the loop to overscope it. It’s the
same least-privilege and blast-radius thinking from the security model, made
automatic.
The developer expressed what — intent, abstract; the platform derived how — the hardening, the exact IAM. The “how” is the part that’s genuinely hard to get right, now impossible to get wrong.
Bounded self-service — the team envelope
Section titled “Bounded self-service — the team envelope”Self-service without a ceiling is how you wake up to 500 orphaned buckets and a surprise bill. So there’s a
governed middle to the model, between your intent and its realization: every Team declares an envelope — the
ceiling on what its environments may ask for — and every claim is validated against it before a single
resource is provisioned. Here’s bravo’s:
envelope: resources: allowedEngines: [s3, sqs, sns, dynamodb] # which engines this team may use at all maxPerEnvironment: 10 # cap on resources per environment isolationFloor: shared # minimum isolation tierThe envelope is default-deny: a team that hasn’t opted into an engine simply can’t request it. And it’s
enforced twice, independently — the gitops gate checks the claim on the pull request, and Kyverno
(restrict-environment-envelope) checks it again at admission — so a request for a disallowed engine, or an
eleventh resource, is rejected before any AWS resource exists. (Same policy engine
that guards everything else on the platform — defense in depth.)
So the full shape has three parts, not two: intent above the claim, validated against your team’s envelope in the middle, realized safely below. Self-service, but inside guardrails your team lead set — a governed claim, not a blank check.
Getting the coordinates to your app — the resources ConfigMap
Section titled “Getting the coordinates to your app — the resources ConfigMap”A bucket you can’t find is useless, so the platform also injects a <service>-resources ConfigMap with the
real, computed coordinates — which is exactly what’s live for dispatch:
shipments-resources ConfigMap: BLOB_BUCKET = refplat-bravo-dispatch-dev-blob-7d258453 REQUESTS_QUEUE_URL = https://sqs.us-east-1.amazonaws.com/<workload-acct>/refplat-bravo-dispatch-dev-requests-… EVENTS_TOPIC_ARN = arn:aws:sns:us-east-1:<workload-acct>:refplat-bravo-dispatch-dev-events-… SHIPMENTS_TABLE = refplat-bravo-dispatch-dev-shipments-982701e1Your app reads $BLOB_BUCKET from its environment — it never hardcodes a resource name. The platform owns the
naming (refplat-<team>-<product>-<stage>-<name>-<hash> — deterministic and collision-safe), so resources can
be renamed or recreated without touching your code.
Any front door, one claim
Section titled “Any front door, one claim”Because the claim is the governed boundary, the way you author it is free. Today that’s a pull request (the
YAML above) or a Backstage form; a designed natural-language agent (“I need somewhere to store user uploads”)
is a future front door — and it changes nothing below the claim, because it still has to produce that same
governed resources: block, which still gets validated and realized the one safe way. New front doors are
cheap; the safety floor is fixed.
When it breaks — the ones you’ll actually hit
Section titled “When it breaks — the ones you’ll actually hit”- “My claim was rejected — ‘engine not allowed’ or ‘over cap’.” Your Team envelope bounds what you can
request (allowed engines,
maxPerEnvironment). It’s default-deny by design, not a bug — widening it is a PR togitops/teams/<team>.yamlby your team lead. - “My pod gets
AccessDeniedon the bucket.” Check theaccesslevel in your claim —readcan’tPutObject. And confirm your pod uses the service’s named ServiceAccount (the derived IAM lands on that Pod-Identity role); a different SA won’t have the policy. - “I don’t know the bucket name.” Don’t hardcode it — read it from the injected
<service>-resourcesConfigMap ($BLOB_BUCKET, etc.). The platform picks the name. - “Can I just tweak the bucket policy / turn on public access?” No — the safety floor is non-overridable by design (TLS-only, no public access, encrypted). If you have a genuine need the abstraction doesn’t cover, that’s a platform conversation, not a setting.
For platform engineers
Section titled “For platform engineers”Below the claim, the Environment Composition renders, per requested
resource: the managed resource itself (s3.aws.m.upbound.io/Bucket, Queue, Topic, Table — namespaced
Crossplane v2 MRs) with its hardening resources (BucketPolicy denying non-TLS, PublicAccessBlock, SSE,
Versioning), a RolePolicy with the derived least-privilege statements appended to the service’s Pod-Identity
role, and the <service>-resources ConfigMap. Access → verbs is a lookup per engine; naming is
refplat-<team>-<product>-<stage>-<name>-<hash>. It’s exercised end-to-end by bravo/dispatch/dev.
Adding a new engine (RDS, ElastiCache, …) is a common platform-engineer task with its own recipe — the catalog is a floor, not a ceiling. That’s the producer side of this module: the how-to Extending: add a new resource engine.
Go deeper
Section titled “Go deeper”- The full schema, engines, and gotchas: the Reference.
- The claim this extends: The Environment API; where the derived IAM lands: Identity & Access; the safe-by-default framing: The Security Model.
- Provision one yourself: the
environment-onboardingskill.