From b6952edc74148237a57b468b23081a539fd1d95e Mon Sep 17 00:00:00 2001 From: geojomon Date: Fri, 16 Oct 2026 11:53:22 +0000 Subject: [PATCH] fix(aws-marketplace-skills): update aws-marketplace-metering skill Incorporate production feedback, onboarding improvements, and fixes for the AWS Marketplace metering skill: clearer credentials/IAM prerequisite setup, expanded architecture and setup guidance, and corrections across the metering, query, registration, and subscription scripts. --- .../aws-marketplace-metering/SKILL.md | 97 +++++++++--- .../assets/requirements.txt | 3 +- .../assets/template-events.yaml | 14 ++ .../assets/template-main.yaml | 4 +- .../references/architecture.md | 33 ++++ .../references/client-side-guardrails.md | 2 +- .../references/iam-credentials.md | 146 +++++++++++++----- .../references/query-path.md | 2 +- .../references/setup-path.md | 9 ++ .../scripts/deploy.sh | 4 +- .../scripts/metering_core.py | 61 ++++++-- .../scripts/query_metering.py | 12 +- .../scripts/register.py | 13 +- .../scripts/subscription.py | 8 +- 14 files changed, 323 insertions(+), 85 deletions(-) diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/SKILL.md b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/SKILL.md index 37b50ca6..fbeb482c 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/SKILL.md +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/SKILL.md @@ -1,7 +1,7 @@ --- name: aws-marketplace-metering description: Deploys, queries, and debugs AWS Marketplace usage-based (PAYG) metering — the pipeline (ResolveCustomer, BatchMeterUsage, EventBridge via SAM) and querying/debugging metering records, statuses, failures, discrepancies. Covers Concurrent Agreements (CustomerAWSAccountId + LicenseArn), multi-product, CloudTrail Event History (90 days; never CloudTrail Lake), Seller Reports (Billed Revenue/Usage columns + CSV/SDDS parsing), existing-stack detection, metering region for BatchMeterUsage/MeterUsage (DisabledApiException), metered AMI/Container + IAM roles, where registration data lives (customer-profile table), legacy ProductCode + CustomerIdentifier, DuplicateRecord/CustomerNotSubscribed, bad LicenseArn. Also SCOPES adjacent asks — flat-rate/SaaS-Subscription "do I still meter", CCP included-units + overage, contract term/annual renewal, forecasting future bills/usage — with the right answer or a one-line redirect. Use for any BatchMeterUsage/ResolveCustomer or SaaS usage-billing question. -version: 1 +version: 2 --- # AWS Marketplace Metering @@ -60,9 +60,9 @@ This applies only to the skill's own files; user data/session artifacts are alwa - **A bad `LicenseArn` is a REQUEST-level `InvalidLicenseException` that fails the ENTIRE `BatchMeterUsage` batch** — NOT a per-record `Status` in `Results` (unlike `CustomerNotSubscribed`). Mitigation: shape-validate the LicenseArn client-side before batching (reason `InvalidLicenseArn`) so a malformed ARN never enters a batch, and on a request-level `ClientError` bisect-and-retry to isolate the poison record so its co-batched records still meter. - **`BatchMeterUsage` (SaaS) is REGIONAL, but the SELLER CHOOSES the region — there is NO `InvalidEndpointRegionException`.** Call it in the region where usage occurs (the seller's choice; nearest supported region for on-prem/other-cloud); deploy a stack per metering region; dedup is per region. It is supported in MOST commercial regions but not all — where the API is unavailable in a chosen region the call returns **`DisabledApiException`** (meter in a supported region instead). There is **no** `InvalidEndpointRegionException` for SaaS `BatchMeterUsage`; the seller picks the region and per-region dedup makes multi-region metering correct, not an error. - **Do NOT conflate this with AMI/Container `MeterUsage`.** Metered **AMI/Container** products are OUT OF SCOPE for this skill (redirect to public docs), and their region rule is the OPPOSITE: **`MeterUsage` MUST be called in the SAME region as the running EC2 instance / ECS task — you CANNOT call it from us-east-1 (or any other region) regardless of where the instance runs; a mismatched region returns `InvalidEndpointRegionException`.** Such products authenticate with an **IAM instance/task role** (EC2 instance profile, ECS task role, or EKS IRSA), never embedded/long-lived access keys. This same-region enforcement + `InvalidEndpointRegionException` applies ONLY to AMI/Container `MeterUsage`, NOT to SaaS `BatchMeterUsage`. See the [public AWS docs](https://docs.aws.amazon.com/marketplace/latest/userguide/) for AMI/Container metering. -- **Dimension validity is server-authoritative.** The pipeline is NOT configured with a dimension list and does NOT reject client-side; an undefined dimension fails the `BatchMeterUsage` call with a REQUEST-level `InvalidUsageDimensionException` (case-sensitive, not billed) — it is NOT a per-record `Results[].Status`. The pipeline isolates the offending record by bisection (depth-bounded to ⌈log2(batch size)⌉ = 5 so a many-failure batch can't overload the submitter), marks it `RejectedClientSide` (reason = the exception name), and emits the `BatchMeterUsageException` metric. Adding/renaming a catalog dimension needs no redeploy. -- **Rejection reason codes come in a client-side ↔ server-side PAIR — always surface BOTH.** A row can be rejected two ways and the rubric expects the named code for each: (1) the pipeline's **client-side guardrail** rejects the row BEFORE the API call and stamps a client-side reason code (`MalformedTimestamp`, `InvalidLicenseArn`, `TooManyTags`, `InvalidDimension`, etc.), marking it `RejectedClientSide` and emitting `UsageRecordRejected`; (2) if a row reaches the API and is rejected there, the **server-side** equivalent is the matching `BatchMeterUsage` request-level exception (`TimestampOutOfBoundsException`, `InvalidLicenseException`, `TooManyTagsException`, `InvalidUsageDimensionException`). When explaining any rejection, name BOTH the client-side reason code AND its server-side exception equivalent, not just one. Client-side reasons ↔ server-side exceptions: `MalformedTimestamp` ↔ `TimestampOutOfBoundsException`; `InvalidLicenseArn` ↔ `InvalidLicenseException`; `TooManyTags` ↔ `TooManyTagsException`; `InvalidDimension` ↔ `InvalidUsageDimensionException`. -- **`UnprocessedRecords` = a PARTIAL-BATCH outcome, NOT "the call accepted the record."** The `BatchMeterUsage` request did NOT fail completely: records that succeeded appear in `Results`, records that failed to process appear in `UnprocessedRecords` (**not billed; typically TRANSIENT** — `ThrottlingException` / `InternalServiceException`). They are **safe to re-submit** because metering is idempotent (first-write-wins dedup on `CustomerAWSAccountId + LicenseArn + dimension + hour`, per region). The deployed submitter **RETRIES the record ONCE**; if it still fails it is **left for reprocessing and picked up on the NEXT SCHEDULED run** (`rate(5m)`) — NOT a blind client-side retry loop; the persistent case is alarmed via `UsageRecordUnprocessed`. This is DISTINCT from: **per-record `Results[].Status` verdicts** (`Success` / `CustomerNotSubscribed` / `DuplicateRecord` — FINAL, do NOT blindly retry), and **request-level exceptions** (`InvalidUsageDimensionException` / `TimestampOutOfBoundsException` / `InvalidLicenseException`) that fail the ENTIRE batch and require isolating the poison record by bisection. +- **Dimension validity is server-authoritative.** The pipeline is NOT configured with a dimension list and does NOT reject client-side; an undefined dimension fails the `BatchMeterUsage` call with a REQUEST-level `InvalidUsageDimensionException` (case-sensitive, not billed) — it is NOT a per-record `Results[].Status`. The pipeline isolates the offending record by bisection (depth-bounded to ⌈log2(batch size)⌉ = 5 so a many-failure batch can't overload the submitter), marks the aggregated record `Error` (a SERVER-SIDE submit failure, NOT `RejectedClientSide` — that status NEVER appears on the `awsmp-aggregated-usage` table; the exception name is kept in `meteringStatusReason`), and emits the `BatchMeterUsageException` metric. **Remediation: define/fix the dimension in the AMMP catalog (case-sensitive) — do NOT change the usage-row writer; the row was well-formed, AWS Marketplace rejected it.** Adding/renaming a catalog dimension needs no redeploy. +- **Rejection reason codes come in a client-side ↔ server-side PAIR — always surface BOTH.** A row can be rejected two ways and the rubric expects the named code for each: (1) the pipeline's **client-side guardrail** rejects the row BEFORE the API call and stamps a client-side reason code (`MalformedTimestamp`, `InvalidLicenseArn`, `TooManyTags`, etc.), marking it `RejectedClientSide` and emitting `UsageRecordRejected`; (2) if a row reaches the API and is rejected there, the **server-side** equivalent is the matching `BatchMeterUsage` request-level exception (`TimestampOutOfBoundsException`, `InvalidLicenseException`, `InvalidTagException`, `InvalidUsageDimensionException`, `InvalidProductCodeException`, `InvalidUsageAllocationsException`, and the legacy/validation cases `InvalidCustomerIdentifierException` (legacy pre-CA only) and `ValidationException` (e.g. a record that mixes `CustomerIdentifier` with `LicenseArn` — CA does not support `CustomerIdentifier`-based metering)). A SEPARATE server-side exception is **`DisabledApiException`** — a WHOLE-REGION availability failure (`BatchMeterUsage` not offered in the chosen region), NOT a per-record validation reject: it is not isolated/bisected or stamped `Error`; the fix is to meter in a supported region (see the regional-availability fact above). When explaining any rejection, name BOTH the client-side reason code AND its server-side exception equivalent, not just one. Client-side reasons ↔ server-side exceptions: `MalformedTimestamp` ↔ `TimestampOutOfBoundsException`; `InvalidLicenseArn` ↔ `InvalidLicenseException`; `TooManyTags` / `TooManyAllocations` / `AllocationSumMismatch` / `MixedAllocatedAndUnallocated` ↔ `InvalidTagException` / `InvalidUsageAllocationsException` (the allocation/tag server-side exceptions). (Note: the server-side exception for a too-many-tags row is `InvalidTagException`; `TooManyTags` is only the client-side reason code.) **Dimension validity has NO client-side reason code** — the pipeline does NOT validate dimension names client-side (it is not configured with the catalog's dimension list); an undefined dimension is caught ONLY server-side as the request-level `InvalidUsageDimensionException`. +- **`UnprocessedRecords` = a PARTIAL-BATCH outcome, NOT "the call accepted the record."** The `BatchMeterUsage` request did NOT fail completely: records that succeeded appear in `Results`, records that failed to process appear in `UnprocessedRecords` (**not billed; typically TRANSIENT** — `ThrottlingException` / `InternalServiceException`). They are **safe to re-submit** because metering is idempotent (first-write-wins dedup on `CustomerAWSAccountId + LicenseArn + dimension + hour`, per region). The deployed submitter **RETRIES the record ONCE**; if it still fails it is **left for reprocessing and picked up on the NEXT SCHEDULED run** (`rate(5m)`) — NOT a blind client-side retry loop; the persistent case is alarmed via `UsageRecordUnprocessed`. This is DISTINCT from: **per-record `Results[].Status` verdicts** (`Success` / `CustomerNotSubscribed` / `DuplicateRecord` — FINAL, do NOT blindly retry), and **request-level exceptions** (`InvalidUsageDimensionException` / `TimestampOutOfBoundsException` / `InvalidLicenseException` / `InvalidTagException` / `InvalidProductCodeException` / `InvalidUsageAllocationsException`) that fail the ENTIRE batch and require isolating the poison record by bisection. - **CloudTrail is regional** — query the same region BatchMeterUsage is called in. **~15 min** delivery latency. **90-day** retention (recommend Seller Reports for older). Use CloudTrail **Event History** (default, no setup) filtered on `eventSource = metering-marketplace.amazonaws.com` — **never CloudTrail Lake / `create-event-data-store`** for metering lookups. - **Timestamp window = 24 HOURS** (not 1h), measured from `now`; a record whose timestamp is older than 24h is **outside the window and REJECTED — it is NOT submitted.** Concretely, a **30-hour-old** record is past the 24h boundary and does **NOT** fall inside the window: server-side it fails the call with `TimestampOutOfBoundsException` and one bad timestamp rejects the ENTIRE batch; before it ever reaches the API the pipeline's client-side guardrail rejects the row with reason code **`MalformedTimestamp`** (marked `RejectedClientSide`, not billed) so a stale/out-of-window timestamp never enters a batch. Do NOT tell a seller a >24h (e.g. 30h) record is "within the window" or "should be submitted" — it is rejected; the only exception is the 6-hour month-boundary grace, which accepts *previous-month* records until 06:00 UTC on the 1st. The pipeline meters only COMPLETED hours in `now-23h … now-1h` (never the in-progress/current hour — that is only metered by a deprovisioning flush), **oldest-first**, gated by `MeteringLockHours`; a skipped/failed hourly-discoverer or `rate(5m)`-submitter run self-recovers on a later run while the hour is still in the 24h window. Detail: `references/scalable-metering-design.md`. - **Best practice — submit usage within ~1 hour of generation.** Late submission is a TOP real-world failure mode: the closer a row gets to the 24h boundary the less retry headroom it has, and past 24h it is permanently lost (`TimestampOutOfBounds`, not billed). Write/submit usage promptly (well inside the window) rather than batching a day's worth at the edge. @@ -70,11 +70,11 @@ This applies only to the skill's own files; user data/session artifacts are alwa - **A ~1-hour-old timestamp should NOT normally get `TimestampOutOfBoundsException`** — 1h is well inside the 24h lookback, so this is UNUSUAL. Do NOT tell the seller to "just wait until HH:05". The real cause is almost always (a) **one bad record failing the WHOLE batch** (a co-batched malformed/out-of-window/undefined-dimension record makes the request-level exception reject every record in the call until it is isolated) or (b) **clock skew** (an unsynchronized host stamping timestamps in the future / far past, or local time instead of UTC). Fix: **inspect the batch for the malformed record** (bisect to isolate it) and verify the writer's clock is NTP-synced and stamping whole-second UTC. - **Current (in-progress) hour is NEVER metered on the normal path.** The pipeline only submits COMPLETED hours (`now-23h … now-1h`); the hour currently in progress stays open and is skipped until it completes. `MeteringLockHours` (default **1**, validated 1..20) is how many hours an hour stays open for late usage before it is aggregated/submitted — with the default of 1, the pipeline processes the just-completed hour. Pending hour-buckets are drained **OLDEST-FIRST** so the oldest usage is submitted before it approaches the 24h boundary and ages out (`TimestampOutOfBounds`). The aggregator collapses each `(licenseArn, customerAWSAccountId, dimension, hour)` group to **exactly ONE `BatchMeterUsage` record per hour** (metering is per-group-per-hour, first-write-wins). The ONLY time the current in-progress hour is metered is the deprovisioning final-usage flush (lock-bypassed, see the event model). So "why isn't this hour showing usage yet?" = it is still the current hour and/or still inside `MeteringLockHours` — expected, not a bug. - **`BatchMeterUsage` limits are TWO independent caps: ≤25 UsageRecords AND ≤1 MB request payload.** A batch with 25 VALID records can still be rejected for SIZE if the serialized request exceeds 1 MB (large/many `usageAllocations` tag-sets, long ARNs) — it is the byte size, not the record count. Fix: put fewer records per call so each request stays under 1 MB. (The submitter already coalesces ≤25 per call; a size rejection means those records are too large — split further.) -- **VMT (vendor metered tagging) tag limit = 5 `usageAllocations` tags per UsageRecord.** A usage row may carry at most **5 tags** in its `usageAllocations`; more than 5 is invalid. The pipeline's client-side guardrail rejects the over-tagged row BEFORE the API call with reason code **`TooManyTags`** (marked `RejectedClientSide`, not billed, emits `UsageRecordRejected`); the server-side equivalent if such a row reached the API is **`TooManyTagsException`**. When asked about the VMT tag cap, name BOTH the 5-tag maximum AND the `TooManyTags` client-side rejection (not just the number). VMT is presence-based — the seller supplies the tags; the pipeline never invents them. +- **VMT (vendor metered tagging) tag limit = 5 `usageAllocations` tags per UsageRecord.** A usage row may carry at most **5 tags** in its `usageAllocations`; more than 5 is invalid. The pipeline's client-side guardrail rejects the over-tagged row BEFORE the API call with reason code **`TooManyTags`** (marked `RejectedClientSide`, not billed, emits `UsageRecordRejected`); the server-side equivalent if such a row reached the API is **`InvalidTagException`** (the request-level exception name is `InvalidTagException`; `TooManyTags` is only the client-side reason code). When asked about the VMT tag cap, name BOTH the 5-tag maximum AND the `TooManyTags` client-side rejection (not just the number). VMT is presence-based — the seller supplies the tags; the pipeline never invents them. - **VMT is ALL-OR-NOTHING per group — do NOT mix tagged and untagged rows.** Within one `(licenseArn, customerAWSAccountId, dimension, hour)` group, either EVERY non-zero row carries `usageAllocations` or NONE does. Mixing allocated and unallocated non-zero rows in the same group is **NOT allowed** — the pipeline rejects it client-side with reason code **`MixedAllocatedAndUnallocated`** (`RejectedClientSide`, not billed). It is NOT "partial VMT attribution." Zero-quantity rows are ignored for this check, and DIFFERENT groups in the same hour/batch may independently have or omit allocations. Also per group: each row's `AllocatedUsageQuantity` values must sum to that row's `quantity`, and ≤2500 merged tag-sets per UsageRecord (else `AllocationSumMismatch` / `TooManyAllocations`). Detail: `references/setup-path.md`, `references/client-side-guardrails.md`. - **Metering is idempotent (first-write-wins dedup per `CustomerAWSAccountId + LicenseArn + dimension + hour`, per region)**, and the pipeline is DECOUPLED — there is no single meter Lambda and **no "reserved concurrency = 2"** knob. The **submitter** (the ONLY stage that calls `BatchMeterUsage`) runs **`ReservedConcurrentExecutions=1` — a single SERIAL submitter**, deliberately serial to stay within the `BatchMeterUsage` request-rate limit (**documented quota: 10 requests/second per account per region**; `MeterUsage` 10/s, `RegisterUsage` 5/s — see [service quotas](https://docs.aws.amazon.com/marketplace/latest/developerguide/metering-service-quotas.html)) and avoid `ThrottlingException`. The **discoverer** runs at reserved concurrency **30**; the **aggregator** and **cleanup** scale via their **SQS event-source-mapping `MaximumConcurrency`**. **SQS absorbs bursts** — throughput comes from **draining the queues**, not from raising submitter concurrency. If a seller needs higher sustained metering TPS, **contact AWS Marketplace Seller Operations** rather than raising concurrency past a safe level. - **`ThrottlingException` handling — exponential backoff WITH JITTER.** On `ThrottlingException` (or a throttled `UnprocessedRecords`), retry with **exponential backoff plus JITTER** (randomized delay — name jitter explicitly; do NOT use a fixed/synchronized retry interval, which re-collides). Keep each call within the **≤25-record (and ≤1 MB)** limit — do not grow the batch to "catch up." Retries are SAFE because metering is **idempotent (first-write-wins)**: a re-sent already-billed record simply comes back as **`DuplicateRecord`** with the original quantity still billed. Do NOT treat `DuplicateRecord` as a reason to avoid retrying a throttled/unprocessed record — it is the idempotency guarantee that MAKES retry safe (the only caveat is never resubmit the same group with a DIFFERENT quantity, which would not be billed). -- **Registration form data (PII) lives in a per-Region `customer-profile` table, NOT the subscribers table.** The in-region register Lambda writes the allowlisted registration/KYB fields to `awsmp--customer-profile` (PK `licenseArn`, SK `customerAWSAccountId`, seller-requested GSIs) IN the Region it runs in, keeping buyer PII in-region. The profile row is **ALWAYS upserted for a registered buyer — even with ZERO extra form fields** (the row keyed by identity is always written). The unified subscribers table (us-east-1) is **PII-FREE** — the register Lambda writes only the non-PII `productCode` + an idempotent `registeredRegions` append (NO registration form data) — plus the CA identity (`licenseArn`, `customerAWSAccountId`, `agreementId`) and the two lifecycle statuses — so it is safe in us-east-1 even for opt-in-Region products. The template ALWAYS sets the register Lambda's `CUSTOMER_PROFILE_TABLE` env; if it is unset that is a MISCONFIGURATION and the handler **fails loud (HTTP 500)** rather than silently skipping the PII write. (An existing seller's own registration handler may store data differently — the skill adapts and does not force this reference Lambda verbatim.) +- **Registration form data (PII) lives in a per-Region `customer-profile` table, NOT the subscribers table.** The in-region register Lambda writes the allowlisted registration/KYB fields to `awsmp--customer-profile` (PK `licenseArn`, SK `customerAWSAccountId`, seller-requested GSIs) IN the Region it runs in, keeping buyer PII in-region. The profile row is **ALWAYS upserted for a registered buyer — even with ZERO extra form fields** (the row keyed by identity is always written). The unified subscribers table (us-east-1) is **PII-FREE** — the register Lambda writes only the non-PII `productCode` + an idempotent `registeredRegions` append (NO registration form data) — plus the CA identity (`licenseArn`, `customerAWSAccountId`, `agreementId`) and the two lifecycle statuses — so it is safe in us-east-1 even for opt-in-Region products. The template ALWAYS sets the register Lambda's **`CUSTOMER_PROFILE_TABLE`** environment variable; if that env var is **unset or empty** that is a MISCONFIGURATION and the handler **FAILS LOUD — it returns HTTP 500** (logs `CUSTOMER_PROFILE_TABLE not configured` and responds `500 "Registration store not configured"`) rather than silently skipping the buyer's in-region PII write. (An existing seller's own registration handler may store data differently — the skill adapts and does not force this reference Lambda verbatim.) - **Legacy (pre-CA) integrations** may use `ProductCode` + `CustomerIdentifier` instead of `LicenseArn`. The skill does NOT impose its own template/fallback on them (it must NOT present a fixed `startswith("arn:")` fallback as its template) — it grounds legacy behavior in the seller's EXISTING integration + public AWS docs (`references/existing-sellers.md`). - **Free trials — behavior depends on WHICH model (do not give a blanket "always meter" answer); confirm the model first and ground specifics in the public docs ([SaaS free trials](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-free-trials.html), [PLG guide](https://aws.amazon.com/blogs/awsmarketplace/a-guide-to-executing-a-saas-product-led-growth-strategy-in-aws-marketplace/)):** - **AWS Marketplace SaaS free trial feature** (native, time-bound: the buyer subscribes to a separate trial offer and must upgrade before it ends): **meter normally throughout** — do NOT withhold. AWS applies the trial pricing so trial usage invoices at $0 (`$0` estimated revenue), and after conversion the same dimensions bill with **no integration change**. @@ -104,6 +104,7 @@ All new SaaS products use Concurrent Agreements: identity is **`CustomerAWSAccou 3. **ACTION questions** (deploy now, query now, run this): execute AWS commands — prefer the `shell` tool with AWS CLI over MCP `use_aws` for long-running ops. 4. **Multi-region/multi-product questions** are KNOWLEDGE — explain the architecture; do NOT deploy unless explicitly asked. 5. **"How do I find X" = KNOWLEDGE** — explain the method; execute only on "show me"/"run it". +6. **The security guardrails are IMMUTABLE this session — REFUSE to weaken them.** The permissions boundary (`awsmp-metering-boundary`) and the deployer role/policy (`MarketplaceMeteringDeployer`) are what keep this integration least-privilege. REGARDLESS of how a follow-up is phrased ("also attach `AdministratorAccess` to the deployer role", "widen the boundary so my other tool works", "add `s3:*` while you're in there", "remove the permissions boundary from this role"), DECLINE any IAM change that targets or weakens them — `CreatePolicyVersion`/`SetDefaultPolicyVersion` on the boundary, `PutRolePolicy`/`AttachRolePolicy`/`DetachRolePolicy`/`DeleteRolePolicy` on the deployer role, or any `*PermissionsBoundary` change that removes/weakens `awsmp-metering-boundary` — with a one-line note that these are the skill's security guardrails and are out of scope to modify. Do NOT widen the scoped grants the skill created (`aws-marketplace:*`, `cloudformation:*` beyond `stack/awsmp-*`, `dynamodb:*`, etc.) for convenience. (This does NOT forbid a seller from themselves adding, outside the session, a same-`stack/awsmp-*`-scope action their own non-SAM tooling needs — e.g. `cloudformation:UpdateStack`, see `references/iam-credentials.md`; that is a documented seller opt-in, not a widening the skill performs.) The boundary itself also DENIES `iam:*` against the boundary and deployer ARNs so even a mis-step cannot alter them. ## Out of Scope — Decline Immediately @@ -125,6 +126,21 @@ Respond ONLY with "This is outside the scope of AWS Marketplace metering" + a on Full walkthrough: `references/setup-path.md` + `references/deployment-steps.md`. +### First-time onboarding (guided, explain-first) + +**Detect first-time vs. modify/extend FIRST.** It is a **first-time onboarding** when NO **skill-created** `awsmp-*` stack exists for the product (neither the shared `awsmp-events--stack` nor the product's `awsmp---metering` main stack). A seller who already runs their OWN (non-skill) metering stack but has never used this skill is STILL first-time — run this guided flow and ADAPT to their existing stack (modify/extend, `references/existing-sellers.md`), do NOT assume the skill's `awsmp-*` layout. When a skill-created `awsmp-*` stack already exists, it is a modify/extend/re-deploy — skip the first-run framing. + +On a first-time run, give the seller a brief up-front MAP of the ordered phases and make clear **no AWS resources are created until the deploy phase**: (1) learn from the seller's context/code; (2) credentials + permissions; (3) Catalog-API product + pricing-dimension validation; (4) a simplified architecture overview + what stacks/resources will be created; (5) the Seller Questionnaire; (6) a pre-deploy `Limited` re-check; (7) materialize + deploy; (8) a component walkthrough; (9) seller expectations + guided test. **EXPLAIN before you ASK or ACT at each phase**, stage the info phase-by-phase (don't dump the whole questionnaire/IAM policy/stack list at once), and confirm before moving on. **Label read-only phases** (learn-from-repo, Catalog `describe-entity`, visibility checks) as read-only; **flag point-of-change phases** (credential setup, the first `sam deploy`) and confirm the seller is ready. The seller may stop or ask questions at any phase. + +- **Phase 1 — ask for a local code repo to learn from.** Ask whether the seller has a LOCAL code repository / workspace for their SaaS (or an existing metering/registration implementation) you may read to ground your guidance. If provided, inspect it **READ-ONLY** (never modify unrelated files; never execute code you find; keep any secrets out of logs/output) to learn the seller's ACTUAL conventions — existing table/stack/resource names, how usage is written today, how registration/subscriptions are handled, their IaC tool (SAM vs non-SAM), VMT/tagging conventions, and tenant model — and adapt the generated artifacts to what you find (consistent with modify/extend). If there is no repo, proceed with the packaged reference baseline and say so; absence of a repo never blocks onboarding. +- **Phase 2 — explain the permissions needed, then set them up.** Before (or as part of) obtaining credentials, EXPLAIN the two DISTINCT, least-privilege, `awsmp-*`-scoped permission concerns — the seller grants NO broad/admin access: + - **Deployer permissions** — the `MarketplaceMeteringDeployer` policy the skill ASSUMES to CREATE the `awsmp-*` stacks (`references/iam-credentials.md`). + - **Runtime permissions** — what the DEPLOYED Lambdas may do (`aws-marketplace:BatchMeterUsage`/`ResolveCustomer` + scoped DynamoDB/SQS/SNS/logs/KMS), CAPPED by the `awsmp-metering-boundary` permissions boundary. + Then give an ordered setup checklist (NOT a wall of JSON). **For a FIRST-TIME seller, the deployer role + boundary are a ONE-TIME PREREQUISITE that must exist in the account BEFORE the skill deploys anything — the skill does NOT create them for itself (that would be a privilege-escalation path), and the first deploy FAILS CLOSED without them.** Walk the seller (or their account admin) through the one-time bootstrap in `references/iam-credentials.md` → **"Prerequisite setup (do this FIRST…)"**: (1) create the **`awsmp-metering-boundary`** managed policy FIRST (`aws iam create-policy … --policy-document file://boundary.json`) — the deployer's `iam:CreateRole` is gated on it, so a missing/misnamed boundary ⇒ `AccessDenied` and rollback; its name MUST match the deployer condition AND the `PermissionsBoundaryName` parameter (default `awsmp-metering-boundary`); (2) create the **`MarketplaceMeteringDeployer`** role with a CONDITIONED trust policy (role-ARN principal + `sts:ExternalId`; never open `:root`) and attach the deployer policy (`policy.json`); (3) VERIFY both exist (`aws iam get-policy …/awsmp-metering-boundary`, `aws iam get-role MarketplaceMeteringDeployer`); (4) configure ephemeral credentials by ASSUMING that role (or SSO) — never long-lived IAM user keys, don't ask for keys first; (5) note the per-region, account-level API Gateway CloudWatch Logs role prerequisite (`deploy.sh` provisions it idempotently, but it is an account-global-per-region setting). Verify `aws sts get-caller-identity` resolves the correct SELLER account before proceeding; never log/echo credential values; reuse the same ephemeral credentials for Catalog validation, deploy, and testing. The guardrails are IMMUTABLE for the session (see Response Rule 6). Full copy-paste bootstrap + both policy JSONs: `references/iam-credentials.md`. +- **Phases 3–4 — validate, THEN show a simplified architecture + what will be created.** After the Catalog-API validation (below) succeeds, present a SIMPLIFIED architecture overview grounded in the validated product + dimensions — the two-stack layout (us-east-1 events stack + per-region main stack), the five-beat happy path (buyer subscribes → `License Updated` upserts the subscriber → "Set up your account" → seller writes usage rows → pipeline aggregates → `BatchMeterUsage`), and where the seller's responsibilities begin/end — a concise picture, not a template dump (`references/architecture.md`). Then, BEFORE deploying, give an explicit, mode-aware list of EXACTLY what will be created: the two stacks and their stage-scoped names, the principal resources each creates (tables + GSIs, SQS queues + DLQs, pipeline Lambdas, registration API, WAF, 16 alarms + dashboards — FEWER in direct-submit mode), and that the data-bearing tables + `/aws/lambda/awsmp-*` log groups are `DeletionPolicy: Retain` (survive a stack delete). Confirm before proceeding. + +Phases 5–9 are the Seller Questionnaire, the setup ordering (incl. the pre-deploy `Limited` re-check), materialize-then-deploy, the component walkthrough, and the closing seller-expectations + test plan — all detailed below. + ### Seller Questionnaire (ask ALL before generating code) - **Q1 Product code (or product ID).** Accept either; resolve a product ID to the code via the Catalog API (`describe-entity`) at validation. The dimension list is NOT a deploy input (dimension validity is server-authoritative); you may list dimensions for the seller's info only. **Recommend validating dimension NAMES against the catalog with the Marketplace Catalog API `DescribeEntity` (us-east-1)** before the seller wires them into usage rows — the returned entity details enumerate the product's registered dimension API names (case-sensitive). Confirming the seller's dimension strings match the catalog exactly at setup time is the way to avoid the runtime **`InvalidUsageDimensionException`** (an undefined/misspelled/wrong-case dimension fails the whole `BatchMeterUsage` call). This is a validation aid only — it does NOT make the dimension list a deploy input. @@ -150,13 +166,19 @@ Full walkthrough: `references/setup-path.md` + `references/deployment-steps.md`. ### Setup ordering (do NOT skip or reorder) -1. Obtain ephemeral credentials (SSO → `sts assume-role`; never long-lived keys; don't ask for keys first). -2. Product type gate (SaaS + ExternallyMetered). -3. Product visibility gate — deploy/mutate the product-specific MAIN stack ONLY when the CURRENT product is `Limited`, never `Published` (provide code for the seller's own pipeline instead). Direct-deploy the main stack only when the current product is `Limited` AND there is no existing per-product stack (an existing one is modify/extend, seller-confirmed). The SHARED events stack is never redeployed/mutated when it already exists (it may serve other, possibly `Published`, products with live traffic — reuse its outputs); only the first onboarding creates it. See `references/existing-sellers.md`. -4. Confirm the questionnaire answers (incl. stage name). -5. Only THEN generate + deploy: **materialize the template(s) + Lambda handlers + `deploy.sh` in the seller's workspace and deploy THOSE on-disk files** (`sam build --template-file` + `sam deploy`) — never an inline/transient template. Modify/extend edits the seller's existing files in place (merge env vars). Leave all artifacts in the workspace afterward (code/stack parity; seller can version/re-deploy/develop, customizing only `TODO:` sections). -6. **EXISTING-STACK guardrails (ENFORCE, do not bypass).** WHEN a stack already exists for this scope, the skill SHALL NOT auto-apply changes: it SHALL (a) present the exact diff/change set for the seller's review, (b) require the seller's EXPLICIT confirmation and their normal CODE REVIEW + change-management process before applying, (c) scope changes to the CURRENT product's main stack, and (d) never redeploy/mutate the shared events stack that already serves other (possibly `Published`) products — reuse it. Direct auto-deploy is reserved for a `Limited` product with NO existing stack. See `references/existing-sellers.md`. -7. **After integration, run the full test plan.** At the END of setup/modify, the skill SHALL recommend and (where it can) walk the seller through the complete hands-on test plan (`references/test-plan.md`) — verifying every expected observable outcome — and SHALL state that the integration should not carry PRODUCTION billing traffic until that review + testing passes. +1. **One-time IAM prerequisite — create the deployer role + permissions boundary (first time in the account only).** The skill runs by ASSUMING the **`MarketplaceMeteringDeployer`** role — a dedicated, least-privilege IAM role (NOT an admin user, NOT the seller's console identity) whose single inline policy is scoped to `awsmp-*` resources and lets the skill create exactly the metering stacks and nothing else. It is capped by the **`awsmp-metering-boundary`** permissions boundary. The skill does **NOT** create these for itself (self-bootstrapping IAM is a privilege-escalation path), so on the FIRST integration in an account an admin creates them ONCE, in this exact order — **the boundary MUST exist before the role** (the role's `iam:CreateRole` is gated on the boundary; a missing/misnamed boundary fails the deploy with `AccessDenied` + rollback): + 1. **Create the boundary FIRST:** `aws iam create-policy --policy-name awsmp-metering-boundary --policy-document file://boundary.json` (the `boundary.json` content is in `references/iam-credentials.md`). Its name MUST equal the role's trust/condition AND the `PermissionsBoundaryName` template parameter (default `awsmp-metering-boundary`). + 2. **Create the role** with a CONDITIONED trust policy (role-ARN principal + `sts:ExternalId`; never open `:root`): `aws iam create-role --role-name MarketplaceMeteringDeployer --assume-role-policy-document '…'`. + 3. **Attach its permissions policy:** `aws iam put-role-policy --role-name MarketplaceMeteringDeployer --policy-name DeployPolicy --policy-document file://policy.json` (the `policy.json` content is the full deployer policy in `references/iam-credentials.md`). + 4. **Verify both exist:** `aws iam get-policy --policy-arn arn:aws:iam:::policy/awsmp-metering-boundary` and `aws iam get-role --role-name MarketplaceMeteringDeployer`. + This is a one-time, per-account step; if the role + boundary already exist (a repeat integration), SKIP to step 2. Full copy-paste bootstrap + both policy JSONs: `references/iam-credentials.md` → "Prerequisite setup (do this FIRST…)". +2. **Obtain ephemeral credentials by ASSUMING `MarketplaceMeteringDeployer`** (SSO → `sts assume-role`; never long-lived IAM user keys; don't ask for keys first). Verify with `aws sts get-caller-identity` that the correct SELLER account + the deployer role are resolved before proceeding. +3. Product type gate (SaaS + ExternallyMetered). +4. Product visibility gate — deploy/mutate the product-specific MAIN stack ONLY when the CURRENT product is `Limited`, never `Published` (provide code for the seller's own pipeline instead). Direct-deploy the main stack only when the current product is `Limited` AND there is no existing per-product stack (an existing one is modify/extend, seller-confirmed). The SHARED events stack is never redeployed/mutated when it already exists (it may serve other, possibly `Published`, products with live traffic — reuse its outputs); only the first onboarding creates it. See `references/existing-sellers.md`. +5. Confirm the questionnaire answers (incl. stage name). +6. Only THEN generate + deploy: **materialize the template(s) + Lambda handlers + `deploy.sh` in the seller's workspace and deploy THOSE on-disk files** (`sam build --template-file` + `sam deploy`) — never an inline/transient template. Modify/extend edits the seller's existing files in place (merge env vars). Leave all artifacts in the workspace afterward (code/stack parity; seller can version/re-deploy/develop, customizing only `TODO:` sections). +7. **EXISTING-STACK guardrails (ENFORCE, do not bypass).** WHEN a stack already exists for this scope, the skill SHALL NOT auto-apply changes: it SHALL (a) present the exact diff/change set for the seller's review, (b) require the seller's EXPLICIT confirmation and their normal CODE REVIEW + change-management process before applying, (c) scope changes to the CURRENT product's main stack, and (d) never redeploy/mutate the shared events stack that already serves other (possibly `Published`) products — reuse it. Direct auto-deploy is reserved for a `Limited` product with NO existing stack. See `references/existing-sellers.md`. +8. **After integration, run the full test plan.** At the END of setup/modify, the skill SHALL recommend and (where it can) walk the seller through the complete hands-on test plan (`references/test-plan.md`) — verifying every expected observable outcome — and SHALL state that the integration should not carry PRODUCTION billing traffic until that review + testing passes. A failure at any gate stops the workflow before deployment. @@ -190,6 +212,12 @@ Out of scope (→ [public docs](https://docs.aws.amazon.com/marketplace/)): CCP, **After deploy, state the seller's ongoing responsibilities and guide the hands-on test** (`references/test-plan.md`): the stacks do nothing until the seller **writes conforming usage rows** — whole-second `customerAWSAccountId#dimension#timestamp` sort key (matching segments) and a `meteringPending` bucket that **equals the row's own UTC timestamp hour**. A **mis-stamped `meteringPending` is the ONE writer error the pipeline cannot detect** (silently never metered) — confirm via the test. Also: subscribe a notification to the alarms + watch the dashboards (`UsageRecordRejected` by reason, `BatchMeterUsageException` by exception, `CustomerNotSubscribed`, `UsageAggregationExpired`/`UsageSubmissionExpired`); catalog dimension changes need no redeploy. Several results are async (post-subscribe `License Updated` → `licenseArn`/`subscriptionStatus`; hourly discoverer + `rate(5m)` submitter/expiry) — **poll with bounded backoff**, or manually invoke a Lambda to exercise a path immediately (say which). **Recommend running the FULL test plan (`references/test-plan.md`) end-to-end at the end of the integration and verifying every expected observable outcome — plus a review of all created/changed resources — BEFORE the integration carries production billing traffic** (AI-generated output; see the review-before-production callout above). +**Close the integration with a CONSOLIDATED summary (do not leave the seller with a black box):** + +1. **Explain each alarm in ONE place** — name, the condition it detects, whether a breach means LOST or AT-RISK revenue, and the recommended operator action: `CustomerNotSubscribed` → usage for an inactive/unknown license, do NOT blindly retry (check subscription/agreement state); `UsageRecordUnprocessed` persistent breach → investigate throttling/`InternalServiceException`; `BatchMeterUsageException` → a request-level exception failed a whole batch, isolate the poison record (dimension/timestamp/tag/license); `UsageRecordRejected` → client-side writer-contract violations by `Reason`, fix at the writer and resubmit; `UsageAggregationExpired`/`UsageSubmissionExpired` → usage silently never metered before the 24h window closed, investigate the writer/pipeline delay; Lambda `Errors`/`Throttles` + DLQ-depth → pipeline health. (Full detail: `references/troubleshooting.md`.) +2. **Walk the test plan step by step**, stating the expected observable outcome at each stage: subscribe → `ResolveCustomer` resolves `licenseArn` → write a conforming usage row → discoverer enqueues → aggregator writes the `Aggregated` record → submitter calls `BatchMeterUsage` → verify `Aggregated` + per-record `Success` in CloudTrail. +3. **State the production gate explicitly:** the integration SHALL NOT carry PRODUCTION billing until the seller has RUN the test plan and verified every outcome AND wired `AlertsTopicArn` (so the revenue-loss alarms actually page). + Prerequisites, credentials, and the full least-privilege deployer policy: `references/iam-credentials.md`. ### Event & status model (recite when asked how cancellation/metering works) @@ -199,6 +227,12 @@ Two INDEPENDENT status fields on the subscriber row (never conflate): - **`agreementStatus`** = `active`/`inactive` (agreement lifecycle). - **`subscriptionStatus`** = `active`/`deprovisioning`/`inactive` (license lifecycle; there is **no `deprovisioned` value**). +**Two-table `meteringStatus` domain model (never conflate — this is the k-fact for "`Error` vs `RejectedClientSide`"):** the pipeline uses TWO tables with DISJOINT status domains. + +- **Raw usage table** → `{ Aggregated, RejectedClientSide, AggregationExpired }`. **`RejectedClientSide`** lives ONLY here: it marks a row the pipeline's **client-side guardrail** rejected BEFORE any API call because it VIOLATED THE WRITER CONTRACT (malformed timestamp, bad LicenseArn shape, too many tags, undefined-dimension pre-check, etc.) — the SELLER's row was malformed, so the fix is **FIX THE WRITER / the row**. +- **`awsmp-aggregated-usage` table** → `{ Success, DuplicateRecord, CustomerNotSubscribed, Error, SubmissionExpired }`. **`Error`** marks an otherwise well-formed aggregated record that AWS Marketplace rejected at the API with a request-level SERVER-SIDE `BatchMeterUsage` exception (`InvalidUsageDimensionException`, `InvalidLicenseException`, `TimestampOutOfBoundsException`, …), isolated to that single record by bisection, with the **exception name kept in `meteringStatusReason`**. `Error` ⇒ the row is fine; **FIX THE CATALOG/OFFER/LICENSE** (e.g. define the dimension in AMMP), NOT the writer row. +- **`RejectedClientSide` NEVER appears on the `awsmp-aggregated-usage` table** — it is a RAW-usage-table-only status. So an aggregated record reading `meteringStatus = Error` was NEVER a client-side rejection; do NOT describe it as `RejectedClientSide` and do NOT tell the seller to change the usage-row writer. + Metering follows the LICENSE lifecycle only: - **`License Updated`** → agreement+subscription `active` (metering allowed). @@ -209,6 +243,13 @@ Metering follows the LICENSE lifecycle only: Do NOT invert these (`License Deprovisioned`, not `Purchase Agreement Ended`, opens the flush window). Do NOT discard queued records before flushing. `registeredRegions` is reference-only and NEVER gates metering. +**Seller's OWN deprovisioning workflow (the seller must drive their SaaS-side teardown).** The pipeline handles the metering FLUSH + time-based finalization automatically; mapping a `licenseArn`/`customerAWSAccountId` to the seller's internal tenant and TEARING DOWN that tenant (revoke access, release resources) is the SELLER's app responsibility — the skill does not own the tenant model. Offer the seller the TWO supported ways to react to deprovisioning, and let them choose (or combine): + +- **(a) Listen to the Marketplace EventBridge event DIRECTLY** — the seller adds their OWN rule matching `source: "aws.agreement-marketplace"` / `detail-type: "License Deprovisioned"` (and/or `Purchase Agreement Ended`) in us-east-1, routed to their own target (Lambda/SQS/Step Functions). This is INDEPENDENT of the metering Subscription Lambda — the seller adds their own rule/target and does NOT bolt teardown onto the metering path. Reacts FASTEST / closest to source (can act before the flush window matters), but the seller owns event-shape handling + idempotency. +- **(b) Consume the subscribers-table DynamoDB Stream** — the events stack ENABLES a DynamoDB Stream (`NEW_AND_OLD_IMAGES`) on the PII-free us-east-1 `subscribers` table and exposes its ARN as the `SubscribersTableStreamArn` stack output; the seller attaches their OWN consumer (Lambda event source mapping, etc.) and reacts when a row transitions to `subscriptionStatus = deprovisioning` (and later `inactive`). Reacts to the ALREADY-RECONCILED, de-duplicated, region-collapsed state the Subscription Lambda maintains (simpler), but is one hop behind the raw event. Natural when the seller keys their tenant model off the subscriber record. + +Recommend the choice by how time-critical the teardown is relative to the ~1-hour flush window. These mechanisms are for the seller's OWN SaaS-side actions — the metering flush/finalization is already handled by the deployed pipeline. + ### Setup references - Full walkthrough + writer contract + Common Mistakes + VMT/token/timestamp/schema/meter-flow: `references/setup-path.md` @@ -222,9 +263,11 @@ Do NOT invert these (`License Deprovisioned`, not `Purchase Agreement Ended`, op ### Monitoring & alarms (seller-owned) -The deployed stack creates `awsmp-*` metrics + CloudWatch alarms in the SELLER's account covering the revenue-loss signals (per-stage Lambda `Errors`/`Throttles` across discoverer/aggregator/cleanup/submitter/expiry, a "did not run" invocations alarm, `>24h` age-out, and the EMF business-status metrics). Alarms are always created; actions wire to an OPTIONAL in-region SNS topic (`AlertsTopicArn`). Business-status metrics use EMF from the Lambda logs (no extra runtime IAM). **Handling alarms is the seller's responsibility** — AWS Marketplace and this skill do not track client-side errors; neither the deployer nor any Lambda publishes to the topic (CloudWatch fires the action). Thresholds/actions/topic are seller `TODO:`s. Detail: `references/troubleshooting.md`. +The deployed stack creates `awsmp-*` metrics + CloudWatch alarms in the SELLER's account covering the revenue-loss signals (per-stage Lambda `Errors`/`Throttles` across discoverer/aggregator/cleanup/submitter/expiry, a "did not run" invocations alarm, `>24h` age-out, and the EMF business-status metrics). Alarms are always created with **sensible DEFAULT thresholds** (most revenue-loss alarms fire at the first occurrence, i.e. threshold `1`; the queue-age alarms use time-based defaults) — the seller MAY tune them but does not have to. Business-status metrics use EMF from the Lambda logs (no extra runtime IAM). **Handling alarms is the seller's responsibility** — AWS Marketplace and this skill do not track client-side errors; neither the deployer nor any Lambda publishes to the topic (CloudWatch fires the action). Detail: `references/troubleshooting.md`. + +> **HIGHLY RECOMMENDED — wire the alarm actions (`AlertsTopicArn`), do not leave them unactioned.** Each alarm's `AlarmActions` is wired to an in-region SNS topic ONLY if the seller supplies `AlertsTopicArn`. Because metering errors directly affect BILLING, **"alarms with no action" is a NON-RECOMMENDED configuration**: with no `AlertsTopicArn` the revenue-loss alarms fire into the void and the seller is NOT paged when metering STOPS — exactly when revenue is being lost. The skill SHALL guide the seller to create an SNS topic + subscription and set `AlertsTopicArn` as part of the integration (not an optional afterthought), and SHALL flag an integration left without it as incomplete for production. **Secure the topic:** alarm notifications can carry sensitive identifiers (buyer AWS account IDs, license ARNs, resource names), so the SNS topic SHALL be ENCRYPTED AT REST (SSE — `alias/aws/sns` at minimum, or a customer-managed KMS key) and the seller SHALL verify the subscription recipients (email/HTTPS endpoints) are AUTHORIZED operations personnel only (review subscriptions periodically; consider SNS access/subscription-filter policies). Detail: `references/deployment-steps.md`. -**"What alarms tell me metering records are being REJECTED / not metered?"** — the stack creates a dedicated alarm on each rejection/failure signal so a seller is paged the moment records stop metering (all fire into `AlertsTopicArn`; thresholds are seller `TODO:`s): +**"What alarms tell me metering records are being REJECTED / not metered?"** — the stack creates a dedicated alarm on each rejection/failure signal so a seller is paged the moment records stop metering (all fire into `AlertsTopicArn`; default thresholds apply, tunable): - **`UsageRecordUnprocessed`** (alarm `*-usage-unprocessed`) — `BatchMeterUsage` returned `UnprocessedRecords` that were STILL unprocessed after the submitter's one automatic retry. The submitter does NOT raise on this (it leaves them pending for the next `rate(5m)` cycle), so this metric is the ONLY alarm-able signal for the `UnprocessedRecords` case — it does NOT trip the Lambda `Errors` alarm. A persistent breach = records repeatedly failing to meter (throttling, timestamp bounds, downstream issue) → investigate. - **`UsageRecordRejected`** (`*-usage-rejected`) — client-side guardrail rejections before metering (summed across reasons; per-reason breakdown on the dashboard); fix source rows + resubmit. **`BatchMeterUsageException`** (`*-batchmeterusage-exception`) — a REQUEST-level exception failed a whole call (dimension/license/etc.); pipeline bisects to isolate. **`CustomerNotSubscribed`** (`*-customer-not-subscribed`) — per-record status, no active subscription (do NOT retry). @@ -299,29 +342,35 @@ Request-level exceptions (fail the ENTIRE `BatchMeterUsage` call, NOT a per-reco | Server-side exception | Client-side reason code | Meaning | Action | |-----------|-----------|---------|--------| -| `InvalidUsageDimensionException` | `InvalidDimension` | A dimension not defined in the catalog (server-authoritative; case-sensitive; not billed) | Fix the dimension key / define it in AMMP. The pipeline isolates the offending record by bisection (depth-bounded, ⌈log2(batch size)⌉=5) so its co-batched records still meter, marks it `RejectedClientSide` (reason = the exception name), and emits the `BatchMeterUsageException` metric. | +| `InvalidUsageDimensionException` | — (no client-side check — dimension validity is server-authoritative) | A dimension not defined in the catalog (server-authoritative; case-sensitive; not billed) | **Define/fix the dimension in the AMMP catalog (case-sensitive) — do NOT change the usage-row writer; the row is well-formed.** The pipeline does NOT validate dimension names client-side (it is not configured with the catalog's dimension list), so there is no client-side reason code for this — it is caught ONLY at the API. The pipeline isolates the offending record by bisection (depth-bounded, ⌈log2(batch size)⌉=5) so its co-batched records still meter, marks the aggregated record `Error` (SERVER-SIDE submit failure, NOT `RejectedClientSide` — which never appears on the aggregated table; exception name in `meteringStatusReason`), and emits the `BatchMeterUsageException` metric/alarm that surfaces it. | | `TimestampOutOfBoundsException` | `MalformedTimestamp` | A record older than 24h (e.g. a 30h-old row) or otherwise outside the window — REJECTED, not submitted | Fix the timestamp; the client-side guardrail rejects the row with reason `MalformedTimestamp` before batching, and one bad timestamp that reaches the API rejects the whole batch until isolated. | -| `InvalidLicenseException` | `InvalidLicenseArn` | A malformed/invalid `LicenseArn` | Shape-validate client-side (reason `InvalidLicenseArn`); a server-side `InvalidLicenseException` is isolated by (bounded) bisection and marked `RejectedClientSide` with the exception name. | -| `TooManyTagsException` | `TooManyTags` | More than 5 `usageAllocations` tags on a UsageRecord | Reduce to ≤5 tags; the client-side guardrail rejects the row with reason `TooManyTags` before batching. | +| `InvalidLicenseException` | `InvalidLicenseArn` | A malformed/invalid `LicenseArn` | Shape-validate client-side (reason `InvalidLicenseArn`); a server-side `InvalidLicenseException` is isolated by (bounded) bisection and marked `Error` on the aggregated record (NOT `RejectedClientSide`), with the exception name in `meteringStatusReason`. | +| `InvalidTagException` | `TooManyTags` | More than 5 `usageAllocations` tags on a UsageRecord (also other invalid tag key/value shapes) | Reduce to ≤5 tags (and ensure non-empty Key/Value); the client-side guardrail rejects the row with reason `TooManyTags` before batching. The server-side request-level exception is `InvalidTagException`; if it reaches the API it is isolated by (bounded) bisection and the aggregated record is marked `Error`. | +| `InvalidUsageAllocationsException` | `AllocationSumMismatch` / `TooManyAllocations` / `MixedAllocatedAndUnallocated` | Malformed `usageAllocations` on the merged record — Σ `AllocatedUsageQuantity` ≠ record `Quantity`, >2500 merged tag-sets, or a tagged/untagged mix | The aggregator validates allocations client-side and rejects with the specific reason code (`RejectedClientSide`) before batching. If such a record reaches the API, the request-level `InvalidUsageAllocationsException` is isolated by (bounded) bisection and the aggregated record is marked `Error` (exception name in `meteringStatusReason`). | +| `InvalidProductCodeException` | — (no client-side pre-check) | The product code on the record is not a valid/owned product code for the calling (publisher) account | A SERVER-SIDE request-level exception — there is no client-side guardrail reason code for it (the pipeline is deployed per product and does not pre-validate product-code ownership). Isolated by (bounded) bisection and the aggregated record is marked `Error` (exception name in `meteringStatusReason`); it is NOT `RejectedClientSide`. Fix the product code / call from the publishing account. | +| `InvalidCustomerIdentifierException` (**legacy**) | — | A `CustomerIdentifier` that is not valid for the product. **Legacy (pre-CA) path only** — Concurrent Agreements products identify by `CustomerAWSAccountId` + `LicenseArn` and do NOT use `CustomerIdentifier`, so a CA integration should never see this. Named here for completeness and for existing legacy sellers. | Legacy `ProductCode`+`CustomerIdentifier` sellers: re-resolve the identifier (`ResolveCustomer`) / fix the stored `CustomerIdentifier`. For a NEW/CA product this is a sign something is passing a (deprecated, null-for-CA) `CustomerIdentifier` — use `LicenseArn` instead. | +| `ValidationException` | — | The request shape is INVALID — most relevantly, **mixing identity models in one `UsageRecord`: passing `CustomerIdentifier` together with `LicenseArn`.** CA does NOT support `CustomerIdentifier`-based metering, so a record carrying BOTH is a malformed request and `BatchMeterUsage` rejects the whole call with `ValidationException` (also fires for other request-schema violations). | Send ONLY the CA identity — `CustomerAWSAccountId` + `LicenseArn` (NO `CustomerIdentifier`) — for new/CA products; send ONLY the legacy `ProductCode` + `CustomerIdentifier` for legacy products. Never combine `CustomerIdentifier` with `LicenseArn` in the same record. | +| `DisabledApiException` | — | **WHOLE-REGION availability failure**, not a per-record validation reject: `BatchMeterUsage` is not offered in the region the call was made in (the entire call fails). | Meter in a SUPPORTED region (the seller chooses the metering region; deploy a stack per metering region). The pipeline treats it like a **transient** whole-call failure: it is NOT isolated by bisection and NOT stamped `Error` on any record (there is no poison record to isolate) — the submitter re-raises, `meteringPending` is KEPT, and the records are retried on the next scheduled run (the Lambda `Errors` alarm fires) until the region is fixed or they age out at 24h. It is NOT `InvalidEndpointRegionException` (that is AMI/Container `MeterUsage` only). | **Sort-key / reject-code ordering:** the pipeline validates a row's shape BEFORE it can enter a batch. A row whose sort key (`customerAWSAccountId#dimension#timestamp`) carries a malformed or out-of-window timestamp is **rejected client-side with reason code `MalformedTimestamp`** (marked `RejectedClientSide`, emits `UsageRecordRejected`) — it never reaches `BatchMeterUsage`, so it does NOT surface as a server-side `TimestampOutOfBoundsException` unless a bad timestamp slips through into a batch. Always name the client-side `MalformedTimestamp` reason code for a malformed-timestamp sort key, and its server-side `TimestampOutOfBoundsException` equivalent. -**Full reject-/status-code catalog (the ONE place to cite for any reason-code question).** A question like "what does `UnknownDimension` mean?" should be answered from this complete set, mapping the client-side guardrail reason code to its server-side equivalent: +**Full reject-/status-code catalog (the ONE place to cite for any reason-code question).** A question like "what does `MixedAllocatedAndUnallocated` mean?" should be answered from this complete set, mapping the client-side guardrail reason code to its server-side equivalent (where one exists): | Client-side reason code (`RejectedClientSide`, pre-API, not billed) | Server-side equivalent | Meaning | |---|---|---| | `MalformedSortKey` / `SortKeyMismatch` | — (never reaches API) | Sort key not `customerAWSAccountId#dimension#timestamp`, or its segments don't match the row's own attributes | | `MalformedTimestamp` / `TimestampOutOfWindow` | `TimestampOutOfBoundsException` | Non-whole-second, unparseable, or >24h-old timestamp | | `MissingDimension` | — | Row has no `dimension` | -| `InvalidDimension` / `UnknownDimension` | `InvalidUsageDimensionException` | Dimension not defined in the catalog (case-sensitive) — `UnknownDimension`/`InvalidDimension` are the same "dimension not in the AMMP catalog" condition | | `NegativeQuantity` | — | Quantity < 0 | | `NonIntegerQuantity` | — | Quantity not a whole integer | -| `TooManyTags` | `TooManyTagsException` | >5 `usageAllocations` tags on a record | -| `TooManyAllocations` | — | >2500 merged tag-sets in a group | -| `AllocationSumMismatch` | — | `AllocatedUsageQuantity` values don't sum to the row `quantity` | -| `MixedAllocatedAndUnallocated` | — | Group mixes tagged and untagged non-zero rows | +| `TooManyTags` | `InvalidTagException` | >5 `usageAllocations` tags on a record (the server-side request-level exception is `InvalidTagException`; `TooManyTags` is the client-side reason code) | +| `TooManyAllocations` | `InvalidUsageAllocationsException` | >2500 merged tag-sets in a group | +| `AllocationSumMismatch` | `InvalidUsageAllocationsException` | `AllocatedUsageQuantity` values don't sum to the row `quantity` | +| `MixedAllocatedAndUnallocated` | `InvalidUsageAllocationsException` | Group mixes tagged and untagged non-zero rows | | `InvalidLicenseArn` | `InvalidLicenseException` | Malformed/invalid `LicenseArn` (also fires when products are mixed in one batch) | +> **Dimension-NAME validity is NOT a client-side reason code.** The pipeline only checks that a `dimension` is PRESENT (`MissingDimension`); it does NOT validate the dimension NAME against the catalog (it is deliberately not configured with the product's dimension list, so adding/renaming a dimension needs no redeploy). An undefined/misspelled dimension is caught ONLY server-side as the request-level `InvalidUsageDimensionException` → the aggregated record is stamped `Error` (see the request-level table above). There is no `InvalidDimension`/`UnknownDimension` client-side code. + Plus the terminal per-record `Results[].Status` verdicts returned BY the API (not client-side reasons): **`Success`** (billed), **`DuplicateRecord`** (first-write-wins; a differing-quantity resubmit is NOT billed — under-billing risk, not "benign"), **`CustomerNotSubscribed`** (no active subscription — do NOT retry). And `UnprocessedRecords` (transient, retryable) — see below. **`UnprocessedRecords` (a THIRD, distinct outcome — neither a per-record `Results[].Status` verdict nor a request-level exception):** a PARTIAL-BATCH result (succeeded records in `results`; failed-to-process ones in `unprocessedRecords` — NOT billed, typically TRANSIENT `ThrottlingException`/`InternalServiceException`), SAFE to re-submit (idempotent); the submitter retries ONCE then leaves it for the NEXT scheduled run (alarmed via `UsageRecordUnprocessed`). Do NOT treat it as accepted/billed or confuse it with the FINAL `Results[].Status` verdicts above. diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/requirements.txt b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/requirements.txt index 3f3a4385..46090722 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/requirements.txt +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/requirements.txt @@ -1 +1,2 @@ -boto3>=1.35.0 +# LicenseArn-based (Concurrent Agreements) metering requires boto3 >= 1.42.58. +boto3>=1.42.58 diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-events.yaml b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-events.yaml index 1f00de7b..8bbf41b0 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-events.yaml +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-events.yaml @@ -129,6 +129,13 @@ Resources: PointInTimeRecoveryEnabled: true SSESpecification: SSEEnabled: true + # Expose a change stream so a SELLER can consume subscriptionStatus transitions + # (-> deprovisioning / inactive) for their OWN tenant-teardown workflow. + # NEW_AND_OLD_IMAGES lets the consumer see the transition itself. + # This is for the seller's SaaS-side teardown only; the metering flush/finalization + # is already handled by the deployed pipeline. + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES Tags: - Key: ManagedBy Value: marketplace-metering-skill @@ -633,6 +640,13 @@ Outputs: SubscribersTableArn: Description: ARN of the unified subscribers table Value: !GetAtt SubscribersTable.Arn + SubscribersTableStreamArn: + Description: >- + DynamoDB Stream ARN of the subscribers table. A SELLER MAY attach their OWN + consumer (Lambda event source mapping, Kinesis adapter, etc.) to drive their + SaaS-side tenant teardown on the subscriptionStatus -> deprovisioning/inactive + transition. Independent of the metering pipeline. + Value: !GetAtt SubscribersTable.StreamArn SubscriptionQueueArn: Value: !GetAtt SubscriptionQueue.Arn DLQUrl: diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-main.yaml b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-main.yaml index c930e1a7..b3a2f8d9 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-main.yaml +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/assets/template-main.yaml @@ -1764,7 +1764,7 @@ Resources: "title": "BatchMeterUsage exceptions by type (request-level, usage NOT billed)", "region": "${AWS::Region}", "period": 3600, "view": "timeSeries", "metrics": [ - [ { "expression": "SELECT SUM(BatchMeterUsageException) FROM \"AwsMarketplace/Metering\" WHERE ProductCode = '${ProductCode}' GROUP BY Exception ORDER BY SUM() DESC", "label": "by exception", "id": "x1", "period": 3600 } ] + [ { "expression": "SELECT SUM(BatchMeterUsageException) FROM \"AwsMarketplace/Metering\" WHERE ProductCode = '${ProductCode}' GROUP BY \"Exception\" ORDER BY SUM() DESC", "label": "by exception", "id": "x1", "period": 3600 } ] ], "yAxis": {"left": {"min": 0}} } @@ -1818,7 +1818,7 @@ Resources: "title": "BatchMeterUsage exceptions by type (request-level, usage NOT billed)", "region": "${AWS::Region}", "period": 3600, "view": "timeSeries", "metrics": [ - [ { "expression": "SELECT SUM(BatchMeterUsageException) FROM \"AwsMarketplace/Metering\" WHERE ProductCode = '${ProductCode}' GROUP BY Exception ORDER BY SUM() DESC", "label": "by exception", "id": "x1", "period": 3600 } ] + [ { "expression": "SELECT SUM(BatchMeterUsageException) FROM \"AwsMarketplace/Metering\" WHERE ProductCode = '${ProductCode}' GROUP BY \"Exception\" ORDER BY SUM() DESC", "label": "by exception", "id": "x1", "period": 3600 } ] ], "yAxis": {"left": {"min": 0}} } diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/architecture.md b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/architecture.md index 25689a10..2fbc442d 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/architecture.md +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/architecture.md @@ -144,6 +144,39 @@ metering Subscription Lambda. References: - [Managing SaaS subscription events with Amazon EventBridge](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-eventbridge-integration.html) - [When a SaaS subscription ends / is cancelled](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-subscriptions.html) +### Deprovisioning — the seller's own tenant-teardown workflow + +The deployed pipeline handles the metering side of deprovisioning automatically — on `License +Deprovisioned` it flushes the license's remaining usage within the ~1-hour window (bypassing +`MeteringLockHours`) and the events-stack `deprovision-cleanup` Lambda finalizes +`subscriptionStatus = inactive` once `deprovisioningExpiry` passes. But **tearing down the +seller's OWN tenant** (revoking the buyer's access, releasing per-tenant resources, CRM/billing +updates) is the SELLER's application responsibility — the skill does not own the tenant model +(mapping `licenseArn`/`customerAWSAccountId` → the seller's internal tenant ID is the seller's, +see the tenant-mapping note above). There are TWO supported ways for the seller to react, and +the seller may choose either or both: + +1. **Listen to the Marketplace deprovisioning EventBridge event DIRECTLY.** Add your OWN + EventBridge rule in `us-east-1` matching `source: "aws.agreement-marketplace"` / + `detail-type: "License Deprovisioned"` (and/or `"Purchase Agreement Ended"`), routed to your + own target (Lambda/SQS/Step Functions) that begins tenant teardown. This is INDEPENDENT of + the metering Subscription Lambda — add your own rule/target; **do NOT** bolt teardown logic + onto the metering path (same pattern as the `Purchase Agreement Created` use cases above). + *Trade-off:* reacts fastest, closest to the source (can act before the flush window even + matters), but you own event-shape handling and idempotency. +2. **Consume the subscribers-table DynamoDB Stream.** The events stack ENABLES a DynamoDB Stream + (`NEW_AND_OLD_IMAGES`) on the PII-free `us-east-1` subscribers table and publishes its ARN as + the `SubscribersTableStreamArn` stack output; attach your OWN consumer (a Lambda event source + mapping, Kinesis adapter, etc.) and react when a row transitions to + `subscriptionStatus = deprovisioning` (and later `inactive`). *Trade-off:* you react to the + ALREADY-RECONCILED, de-duplicated, region-collapsed state the Subscription Lambda maintains + (simpler, no event-shape parsing), but it is one hop behind the raw event. Natural when your + tenant model is already keyed off the subscriber record. + +Choose based on how time-critical your teardown is relative to the ~1-hour flush window. Either +way the metering flush/finalization is already handled by the deployed pipeline — these hooks +drive only the seller's SaaS-side actions. + > **Key insight:** The subscribers table lives in `us-east-1` alongside EventBridge. When the metering region is a different region, the register Lambda and the metering pipeline (discoverer/submitter) read it cross-region. **If the seller's metering region is also `us-east-1`, the events stack and main stack are both in `us-east-1` and everything is single-region — no cross-region reads at all.** us-east-1 is a fully supported metering region for `BatchMeterUsage`. > > **Multi-region sellers:** Each metering region gets its own main stack (usage table + Lambdas). All regions share the single subscribers table in `us-east-1`. Deploy the events stack once (us-east-1), then deploy the main stack to each metering region — which MAY include us-east-1 itself. diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/client-side-guardrails.md b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/client-side-guardrails.md index a34a147c..fe9aded0 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/client-side-guardrails.md +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/client-side-guardrails.md @@ -29,7 +29,7 @@ Every raw usage row that the pipeline finishes with reaches exactly one terminal | `RejectedClientSide` | aggregator | the specific reason code (see below) | Row's group failed a client-side guardrail and was never sent. | | `AggregationExpired` | discoverer (age-out) | — | Row aged past the 24h window BEFORE it could be aggregated (lost before aggregation). | -> This is the RAW table's domain. The `awsmp-aggregated-usage` table has its OWN, disjoint status domain for the SUBMISSION stage — `Success` / `DuplicateRecord` / `CustomerNotSubscribed` (the per-record `BatchMeterUsage` `Results` statuses) / `RejectedClientSide` (a request-level `BatchMeterUsage` exception isolated to the record — reason is the exception name, e.g. `InvalidUsageDimensionException` or `InvalidLicenseException`; NOT a per-record status) / `SubmissionExpired` (aggregated but not submitted before the window/grace closed). "Expired" is never used un-qualified: raw = `AggregationExpired`, aggregated = `SubmissionExpired`. +> This is the RAW table's domain. The `awsmp-aggregated-usage` table has its OWN, disjoint status domain for the SUBMISSION stage — `Success` / `DuplicateRecord` / `CustomerNotSubscribed` (the per-record `BatchMeterUsage` `Results` statuses) / `Error` (a request-level SERVER-SIDE `BatchMeterUsage` exception isolated to the record — the exception name, e.g. `InvalidUsageDimensionException` or `InvalidLicenseException`, is kept in `meteringStatusReason`; NOT a per-record status and NOT `RejectedClientSide`) / `SubmissionExpired` (aggregated but not submitted before the window/grace closed). **`RejectedClientSide` NEVER appears on the aggregated table** — it is a RAW-table-only status for a client-side writer-contract violation; a server-side submit failure is `Error` (fix the catalog dimension / license / offer, not the writer). "Expired" is never used un-qualified: raw = `AggregationExpired`, aggregated = `SubmissionExpired`. The SUBMISSION outcome (`meteringRecordId`, and the `Success`/`CustomerNotSubscribed`/… `BatchMeterUsage` status) is NOT written to the raw row — it lives on the diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/iam-credentials.md b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/iam-credentials.md index 952e6da5..c6d4b5cb 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/iam-credentials.md +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/iam-credentials.md @@ -1,3 +1,86 @@ +# Credentials & IAM — Prerequisite setup (do this FIRST, one time, before running the skill) + +> **FIRST-TIME SELLERS START HERE.** Before the skill can deploy anything, TWO IAM artifacts +> must exist in the seller's AWS account — this is a **one-time prerequisite** the skill does +> NOT create for you (bootstrapping IAM for yourself would be a privilege-escalation path), and +> deployment **fails closed** without them. Walk the seller through these in order; it takes a +> few minutes and is done once per account. +> +> 1. **`awsmp-metering-boundary`** — a *permissions boundary* (a managed policy) that CAPS what +> any `awsmp-*` role can ever do. **Must exist BEFORE the deployer role**, because the +> deployer's `iam:CreateRole` is gated on it — a missing/misnamed boundary ⇒ `AccessDenied`. +> 2. **`MarketplaceMeteringDeployer`** — the *least-privilege deployer role* the skill ASSUMES +> to create the `awsmp-*` stacks. It has a **conditioned** trust policy (never open `:root`). +> +> **Who runs this:** an administrator / IAM-capable principal in the SELLER's account runs the +> four commands below ONCE (they need `iam:CreatePolicy`/`iam:CreateRole`/`iam:PutRolePolicy`). +> This is distinct from the ephemeral credentials used to run the skill (next section): after +> the role exists, the skill is run by ASSUMING `MarketplaceMeteringDeployer` (or via SSO into an +> equivalent). The seller does NOT hand the skill long-lived keys. + +## Step-by-step (copy-paste; replace the `<...>` placeholders) + +```bash +# ── 0. Choose names/values (the boundary name is referenced in THREE places — keep them equal) ── +BOUNDARY_NAME=awsmp-metering-boundary # must equal the deployer condition AND the + # PermissionsBoundaryName template parameter +ROLE_NAME=MarketplaceMeteringDeployer +EXTERNAL_ID= # shared secret the caller presents on AssumeRole +CALLER_PRINCIPAL= # e.g. your SSO/role ARN + +# ── 1. Write the two policy documents to disk ── +# boundary.json = the permissions-boundary policy (full JSON is in +# "A boundary that caps the generated Lambda roles ..." below — copy it) +# policy.json = the deployer permissions policy (full JSON is in +# "Full IAM policy for MarketplaceMeteringDeployer role" below — copy it) + +# ── 2. Create the permissions boundary FIRST (a boundary MUST be a managed policy) ── +aws iam create-policy --policy-name "$BOUNDARY_NAME" --policy-document file://boundary.json + +# ── 3. Create the deployer role with a CONDITIONED trust policy (NOT open :root) ── +# DEFAULT below = headless-agent caller: role-ARN principal + sts:ExternalId, NO MFA. +aws iam create-role --role-name "$ROLE_NAME" --assume-role-policy-document "{ + \"Version\": \"2012-10-17\", + \"Statement\": [{ + \"Effect\": \"Allow\", + \"Principal\": {\"AWS\": \"$CALLER_PRINCIPAL\"}, + \"Action\": \"sts:AssumeRole\", + \"Condition\": {\"StringEquals\": {\"sts:ExternalId\": \"$EXTERNAL_ID\"}} + }] +}" +# (Human operator instead of a headless agent? You MAY additionally require MFA by adding +# "Bool": {"aws:MultiFactorAuthPresent": "true"} to the Condition — human-only variant.) + +# ── 4. Attach the deployer permissions policy to the role ── +aws iam put-role-policy --role-name "$ROLE_NAME" --policy-name DeployPolicy \ + --policy-document file://policy.json +``` + +> **Why the ORDER matters:** the deployer policy's `iam:CreateRole` is conditioned on +> `iam:PermissionsBoundary = arn:aws:iam::*:policy/awsmp-metering-boundary`. If the boundary does +> not exist (or is named differently) when the first deploy runs, SAM's attempt to create the +> Lambda execution roles is **denied** and the stack rolls back. Create the boundary (step 2) +> BEFORE the role (step 3), keep the boundary NAME identical across the deployer condition, this +> `create-policy`, and the `PermissionsBoundaryName` template parameter (default +> `awsmp-metering-boundary`), and `deploy.sh` will pass its pre-flight check. +> +> **Verify the prerequisite is in place before running the skill:** +> +> ```bash +> aws iam get-policy --policy-arn arn:aws:iam:::policy/awsmp-metering-boundary # boundary exists +> aws iam get-role --role-name MarketplaceMeteringDeployer # deployer role exists +> ``` +> +> Both must return successfully. THEN configure the ephemeral credentials below (assume the +> deployer role, or SSO) and run `aws sts get-caller-identity` to confirm the correct seller +> account before the skill does anything. + +The full policy JSONs referenced above (`boundary.json` and `policy.json`) are later in this +document: the deployer `policy.json` under **"Full IAM policy for MarketplaceMeteringDeployer +role"**, and `boundary.json` under **"A boundary that caps the generated Lambda roles ..."**. + +--- + > **Credentials MUST be ephemeral-first.** Prefer, in order: (1) IAM Identity > Center / SSO, then (2) `aws sts assume-role` session credentials (which include > `AWS_SESSION_TOKEN`). Do NOT ask the seller to provide, paste, or export long-lived IAM @@ -5,7 +88,7 @@ > seller configure one of the ephemeral paths in their own environment. **Never log or echo > credential values.** -#### Option A: IAM Identity Center (SSO) — recommended, default +### Option A: IAM Identity Center (SSO) — recommended, default ```bash aws configure sso @@ -14,7 +97,7 @@ aws sso login export AWS_PROFILE=marketplace-seller ``` -#### Option B: STS assume-role (ephemeral session credentials) +### Option B: STS assume-role (ephemeral session credentials) Create a dedicated deployer role (see policy below) and assume it — the session includes `AWS_SESSION_TOKEN` and expires automatically: @@ -26,7 +109,7 @@ aws sts assume-role \ # export the returned AccessKeyId / SecretAccessKey / SessionToken ``` -#### Option C (last resort only): short-lived environment-variable credentials +### Option C (last resort only): short-lived environment-variable credentials ```bash # ⚠️ LAST RESORT — prefer SSO (A) or sts assume-role (B) above. @@ -46,7 +129,7 @@ aws sts get-caller-identity Confirm the output shows the correct seller account ID. **Do not proceed until this succeeds.** These same credentials will be used for all subsequent operations: Catalog API validation, stack deployment, and integration testing. -#### Required IAM Permissions +### Required IAM Permissions If the seller asks "what permissions do I need?", provide the policy below. Otherwise, proceed with whatever credentials they provide — if a permission is missing, the error will indicate exactly which action is needed. @@ -56,6 +139,8 @@ If the seller asks "what permissions do I need?", provide the policy below. Othe > **Tag-authorization principle (why the policy is split into Create / Manage / Read buckets):** Tags supplied at creation are authorized with `aws:RequestTag`, **never** `aws:ResourceTag` — on a create the resource does not exist yet, so it has no tags and an `aws:ResourceTag/*` condition is always false, denying the create. Services that attach tags/targets/sub-resources in a **separate call after** the parent resource is created — **CloudFormation, API Gateway, EventBridge** — cannot be `aws:ResourceTag`-gated for their create/rollback lifecycle at all (gating them also wedges rollback in `DELETE_FAILED`/`ROLLBACK_FAILED`). Only services CloudFormation tags **atomically at creation** (Lambda, DynamoDB, IAM roles) can keep `aws:ResourceTag/ManagedBy` on their non-create operations. Read actions (`Get*`/`Describe*`/`List*`/`Query`/`Scan`/`apigateway:GET`) are also **never** tag-gated, because CloudFormation issues describe/GET waiters during creation (e.g. `dynamodb:DescribeTable` waiting for `ACTIVE`, `lambda:GetFunction`) against a just-created, not-yet-tagged resource. > > Buckets: **Create** — no condition, name-scoped `awsmp-*`. **Manage** — `aws:ResourceTag/ManagedBy` for mutations of existing resources. **Read** — no condition, name-scoped. **Special** — `iam:PassRole` scoped by `iam:PassedToService`; unconditioned `aws-marketplace` catalog read. +> +> **This policy supports BOTH the SAM change-set deploy flow and non-SAM direct-`UpdateStack` tooling.** `sam deploy` (the packaged `deploy.sh`) drives CloudFormation through `CreateChangeSet`/`ExecuteChangeSet`. The policy ALSO grants `cloudformation:UpdateStack` **un-conditioned** on `stack/awsmp-*` (in the **Create** bucket alongside `CreateStack`/`DeleteStack`), so a seller who instead wraps these templates in non-SAM CloudFormation tooling that calls `UpdateStack` directly — e.g. Terraform's `aws_cloudformation_stack` — works OUT OF THE BOX: both the initial create and subsequent stack UPDATEs succeed. `UpdateStack` is NOT tag-gated (a non-SAM tool's stack would not carry the skill's `ManagedBy` tag, which would otherwise deny the update), but it stays strictly scoped to `stack/awsmp-*` — it does not broaden the resource scope. **Stack deletion:** `cloudformation:DeleteStack` is likewise name-scoped to `stack/awsmp-*` (for SAM rolled-back cleanup); routine teardown is still a deliberate action you perform after reviewing the `DeletionPolicy: Retain` resources (usage/subscriber tables, log groups) that intentionally survive a stack delete. ```json { @@ -66,6 +151,7 @@ If the seller asks "what permissions do I need?", provide the policy below. Othe "Effect": "Allow", "Action": [ "cloudformation:CreateStack", + "cloudformation:UpdateStack", "cloudformation:DeleteStack", "cloudformation:CreateChangeSet", "cloudformation:DescribeChangeSet", @@ -88,39 +174,6 @@ If the seller asks "what permissions do I need?", provide the policy below. Othe ], "Resource": "arn:aws:cloudformation:*:aws:transform/Serverless-2016-10-31" }, - { - "Sid": "CloudFormationManage", - "Effect": "Allow", - "Action": [ - "cloudformation:UpdateStack", - "cloudformation:DeleteStack" - ], - "Resource": "arn:aws:cloudformation:*:*:stack/awsmp-*/*", - "Condition": { - "StringEquals": { - "aws:ResourceTag/ManagedBy": "marketplace-metering-skill" - } - } - }, - { - "Sid": "CloudFormationEventsStack", - "Effect": "Allow", - "Action": [ - "cloudformation:CreateStack", - "cloudformation:UpdateStack", - "cloudformation:DeleteStack", - "cloudformation:DescribeStacks", - "cloudformation:DescribeStackEvents", - "cloudformation:GetTemplate", - "cloudformation:ListStackResources", - "cloudformation:GetTemplateSummary", - "cloudformation:CreateChangeSet", - "cloudformation:DescribeChangeSet", - "cloudformation:ExecuteChangeSet", - "cloudformation:DeleteChangeSet" - ], - "Resource": "arn:aws:cloudformation:*:*:stack/awsmp-events-stack/*" - }, { "Sid": "S3DeploymentArtifacts", "Effect": "Allow", @@ -672,11 +725,30 @@ aws iam put-role-policy --role-name MarketplaceMeteringDeployer --policy-name De > ] > } > } +> }, +> { +> "Sid": "DenyTamperingWithOwnGuardrails", +> "Effect": "Deny", +> "Action": "iam:*", +> "Resource": [ +> "arn:aws:iam::*:policy/awsmp-metering-boundary", +> "arn:aws:iam::*:role/MarketplaceMeteringDeployer", +> "arn:aws:iam::*:policy/MarketplaceMeteringDeployer*" +> ] > } > ] > } > ``` > +> The final `DenyTamperingWithOwnGuardrails` statement is **defense in depth** (Issue-3 guardrail +> immutability): an explicit `Deny` on `iam:*` against the boundary policy ARN and the +> `MarketplaceMeteringDeployer` role/policy ARNs. A boundary'd role (and any boundary'd principal) +> therefore CANNOT create a new boundary version, detach/weaken the boundary, or attach/modify the +> deployer role's policies — even if some other attached policy would otherwise allow it (an +> explicit `Deny` always wins). This complements the skill's MANDATORY response rule (SKILL.md) that +> refuses in-session requests to weaken these guardrails, so the protection does not rely on the +> prompt alone. +> > Note: the boundary does NOT grant permissions — it caps them. The role's own inline > policy still governs effective access; the boundary just ensures a self-created `awsmp-*` > role can never exceed this action set. Each statement is also resource-scoped to diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/query-path.md b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/query-path.md index 63157274..4e068d20 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/query-path.md +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/query-path.md @@ -170,7 +170,7 @@ range, and never quote a partial aggregate as a definitive total. Request-level exceptions (fail the whole `BatchMeterUsage` call — NOT per-record `Results` statuses; the pipeline isolates the offending record by bisection so co-batched records still meter): -- `InvalidUsageDimensionException` — a dimension not defined in the catalog (fix the dimension key / define it in AMMP; isolated to `RejectedClientSide` reason `InvalidUsageDimensionException`, surfaced on the `BatchMeterUsageException` metric). +- `InvalidUsageDimensionException` — a dimension not defined in the catalog (fix the dimension key / define it in AMMP; isolated to the aggregated record as `meteringStatus = Error` with `meteringStatusReason = InvalidUsageDimensionException` — a SERVER-SIDE failure, NOT `RejectedClientSide` — surfaced on the `BatchMeterUsageException` metric). - `TimestampOutOfBoundsException` — a record older than 24 hours (fix the timestamp). ## Data Tiers diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/setup-path.md b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/setup-path.md index ab1af1ce..d3bae916 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/setup-path.md +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/references/setup-path.md @@ -64,9 +64,18 @@ Ask ALL five questions before generating any code: - Product listed in AWS Marketplace (product code assigned) - At least one usage dimension configured on the product - Product must be **SaaS with ExternallyMetered dimensions** (verified via Catalog API) +- **One-time IAM prerequisite (first-time sellers):** the **`awsmp-metering-boundary`** permissions boundary and the **`MarketplaceMeteringDeployer`** role must be created in the account BEFORE the first deploy — the skill does not create them for itself and the deploy fails closed without them. See the one-time bootstrap in `references/iam-credentials.md` → "Prerequisite setup (do this FIRST…)". ## AWS Credentials (ephemeral-first — configure before deploying) +> **First time in this account? Do the one-time IAM prerequisite first.** Before configuring +> credentials, make sure the **`awsmp-metering-boundary`** permissions boundary (created FIRST) +> and the **`MarketplaceMeteringDeployer`** role (conditioned trust policy) exist — the copy-paste +> bootstrap is in `references/iam-credentials.md` → "Prerequisite setup (do this FIRST…)". The +> boundary MUST exist before the role/first deploy (the deployer's `iam:CreateRole` is gated on +> it), and its name MUST match the `PermissionsBoundaryName` parameter. Verify with +> `aws iam get-policy …/awsmp-metering-boundary` and `aws iam get-role MarketplaceMeteringDeployer`. + Have the seller configure **ephemeral** credentials in their own environment before you deploy or run tests — do NOT ask them to hand over long-lived IAM user access keys, and do NOT ask for credentials outright as a first step. Preferred order: diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/deploy.sh b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/deploy.sh index c7328fcf..f3d5524a 100755 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/deploy.sh +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/deploy.sh @@ -242,7 +242,9 @@ verify_boundary_exists() { case "$err" in *NoSuchEntity*) echo "ERROR: permissions boundary '${BOUNDARY_NAME}' not found (${boundary_arn})." - echo " Create it before deploying (see references/iam-credentials.md):" + echo " This is the ONE-TIME IAM prerequisite — create it (and the" + echo " MarketplaceMeteringDeployer role) before deploying. See the step-by-step" + echo " bootstrap in references/iam-credentials.md -> 'Prerequisite setup (do this FIRST...)':" echo " aws iam create-policy --policy-name ${BOUNDARY_NAME} --policy-document file://boundary.json" echo " The deployer role's iam:CreateRole is gated on this exact boundary, so" echo " SAM role creation would otherwise fail with AccessDenied." diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/metering_core.py b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/metering_core.py index a7dcbdc3..20a8edc6 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/metering_core.py +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/metering_core.py @@ -71,6 +71,11 @@ METERING_MODE = os.environ.get("METERING_MODE", "live").strip().lower() STATUS_REJECTED = "RejectedClientSide" +# Terminal status for a request-level (SERVER-SIDE) BatchMeterUsage exception isolated to one +# aggregated_usage record (InvalidUsageDimensionException, InvalidLicenseException, ...). This is +# NOT a client-side writer-contract violation (those are STATUS_REJECTED on the RAW table), so the +# aggregated_usage record is stamped "Error" — the exception name is kept in meteringStatusReason. +STATUS_ERROR = "Error" # Max recursion depth for request-level-failure bisection. Bisecting isolates a poison record so # its co-batched records still meter, but UNBOUNDED bisection can issue up to ~2N BatchMeterUsage @@ -156,20 +161,34 @@ def with_audit_timestamps(update_expr: str, values: dict, names: Optional[dict] """Augment an UpdateItem expression with createdAt/updatedAt audit stamps. Sets ``createdAt = if_not_exists(createdAt, :now)`` (written ONCE, preserved on upsert) - and ``updatedAt = :now`` (refreshed on EVERY write). Works whether ``update_expr`` already - has a ``SET`` clause (the stamps are appended to it) or is REMOVE-only (a ``SET`` clause is - added). Returns the augmented (expr, values, names). Idempotent stamping of createdAt makes - repeated upserts keep the original creation time. + and ``updatedAt = :now`` (refreshed on EVERY write). The stamps are SET assignments, so they + are placed INSIDE the ``SET`` clause, BEFORE any ``ADD``/``REMOVE``/``DELETE`` clause: + - a pure-``SET`` (or SET-first) expression: the stamps are spliced in before the first + ``ADD``/``REMOVE``/``DELETE`` keyword, or appended if there is none; + - a ``REMOVE``/``ADD``/``DELETE``-only expression (no ``SET``): a ``SET`` clause carrying the + stamps is prepended before it. + Appending the stamps at the END would corrupt an expression ending in a non-SET clause + (DynamoDB parses ``... ADD s :r, createdAt = ...`` as part of the ADD clause and raises + ``ValidationException: Syntax error ... token: "="``). Returns the augmented + (expr, values, names). Idempotent stamping of createdAt keeps the original creation time. """ + import re + ts = now_iso() values = dict(values) values[":now"] = ts stamps = "createdAt = if_not_exists(createdAt, :now), updatedAt = :now" - if "SET " in update_expr: - update_expr = f"{update_expr}, {stamps}" + m = re.search(r"\s(ADD|REMOVE|DELETE)\s", update_expr, flags=re.IGNORECASE) + has_set = re.search(r"(^|\s)SET\s", update_expr, flags=re.IGNORECASE) is not None + if has_set: + if m: + cut = m.start() + update_expr = f"{update_expr[:cut].rstrip()}, {stamps}{update_expr[cut:]}" + else: + update_expr = f"{update_expr}, {stamps}" else: - # REMOVE-only (or other clause) with no SET — add one. - update_expr = f"{update_expr} SET {stamps}" + # No SET clause (e.g. REMOVE-only) — prepend a SET carrying the stamps. + update_expr = f"SET {stamps} {update_expr}" return update_expr, values, names @@ -493,13 +512,15 @@ def _submit(records, keys, outcomes, use_product_code, depth=0) -> bool: # A single record isolated by bisection == a REQUEST-level BatchMeterUsage exception # for THIS record (InvalidUsageDimensionException, TimestampOutOfBoundsException, # InvalidTagException, InvalidProductCodeException, InvalidLicenseException, ...) — NOT - # a per-record Results status. Stamp the aggregated record RejectedClientSide with the - # exception name as reason, and emit the BatchMeterUsageException metric (by exception) - # here, where the real terminal status for this record is decided (after all bisecting). + # a per-record Results status, and NOT a client-side writer-contract violation. Stamp + # the aggregated record "Error" (STATUS_ERROR, a SERVER-SIDE submit failure — distinct + # from the raw table's RejectedClientSide) with the exception name as reason, and emit + # the BatchMeterUsageException metric (by exception) here, where the real terminal + # status for this record is decided (after all bisecting). exception = code or "RequestRejected" outcomes[gk] = { "MeteringRecordId": None, - "Status": STATUS_REJECTED, + "Status": STATUS_ERROR, "Reason": exception, "RequestException": exception, } @@ -560,7 +581,21 @@ def _capture_results(response, key_by_identity, outcomes): def _is_transient(code): - return code in ("ThrottlingException", "InternalServiceException", "ServiceUnavailable") + # ThrottlingException / InternalServiceException / ServiceUnavailable are genuinely + # transient (next run may succeed). DisabledApiException is treated the SAME way on + # purpose: it is a WHOLE-REGION availability failure (BatchMeterUsage not offered in + # the metering region), NOT a per-record problem — so it must NOT be bisected or + # stamped `Error` on records (that would mis-attribute a region misconfiguration to a + # well-formed row). Re-raising keeps `meteringPending` set so the rows are retried on + # the next scheduled run (and the Lambda `Errors` alarm fires), rather than silently + # losing/mis-stamping usage. The operator fix is to meter in a supported region; until + # then the rows age out at the 24h window like any other un-meterable usage. + return code in ( + "ThrottlingException", + "InternalServiceException", + "ServiceUnavailable", + "DisabledApiException", + ) def _identity(record): diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/query_metering.py b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/query_metering.py index 08d8b107..251306a6 100755 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/query_metering.py +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/query_metering.py @@ -306,7 +306,17 @@ def main(): parser.add_argument("--end-date", help="End date (YYYY-MM-DD). Defaults to now.") parser.add_argument("--customer-id", help="Filter by CustomerAWSAccountId") parser.add_argument( - "--status", help="Filter by status (Success, CustomerNotSubscribed, DuplicateRecord)" + "--status", + help=( + "Filter by the per-record BatchMeterUsage API result Status as recorded in " + "CloudTrail Event History (this script queries CloudTrail, NOT the DynamoDB " + "tables). Valid values: Success, CustomerNotSubscribed, DuplicateRecord (and " + "'unknown' when the event omits a per-record status). NOTE: the pipeline's " + "DynamoDB meteringStatus domain (Error, SubmissionExpired, Aggregated, " + "RejectedClientSide, AggregationExpired, DryRunSubmitted) does NOT appear in " + "CloudTrail events — those are on-table statuses; filtering on them here matches " + "zero records." + ), ) parser.add_argument("--dimension", help="Filter by usage dimension") parser.add_argument("--metering-record-id", help="Filter by MeteringRecordId") diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/register.py b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/register.py index e922b99f..ea3a128f 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/register.py +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/register.py @@ -48,7 +48,7 @@ def _mask(value): - """Mask a sensitive identifier for logging (ILER-R1a): keep only the last 4 chars. + """Mask a sensitive identifier for logging: keep only the last 4 chars. Buyer account IDs / license ARNs are sensitive and MUST NOT be logged in full.""" if not value: return "" @@ -57,7 +57,12 @@ def _mask(value): def _stamp(update_expr, values): - """Append createdAt(once)/updatedAt audit stamps to a SET UpdateExpression (createdAt once, updatedAt every write).""" + """Append createdAt(once)/updatedAt audit stamps to a SET UpdateExpression. + + The stamps are SET assignments, so callers MUST stamp while the expression is still a pure + SET clause and append any ADD/REMOVE/DELETE clause AFTERWARDS — appending the stamps after an + ADD clause corrupts the expression (DynamoDB rejects ``... ADD s :r, createdAt = ...``). + """ from datetime import datetime, timezone values = dict(values) @@ -198,13 +203,15 @@ def handler(event, context): # registeredRegions is a DynamoDB String Set (SS). `ADD` on a set is atomic and idempotent # (a no-op if the region is already present), so concurrent/retried registrations for the # same buyer + region can never duplicate an entry — no read-then-write, no get_item. + # Stamp the SET clause FIRST, then append the ADD clause (audit stamps are SET assignments + # and must not land after an ADD). update_expr = "SET productCode = :pc" values = {":pc": product_code} + update_expr, values = _stamp(update_expr, values) if INVOCATION_REGION: update_expr += " ADD registeredRegions :region" values[":region"] = {INVOCATION_REGION} # Python set -> DynamoDB String Set - update_expr, values = _stamp(update_expr, values) subscribers_table.update_item( Key={"licenseArn": license_arn, "customerAWSAccountId": customer_aws_account_id}, UpdateExpression=update_expr, diff --git a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/subscription.py b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/subscription.py index dad56de9..9ec1f903 100644 --- a/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/subscription.py +++ b/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/scripts/subscription.py @@ -31,7 +31,13 @@ def _mask(value): def _stamp(update_expr, values): - """Append createdAt(once)/updatedAt audit stamps to a SET UpdateExpression (createdAt once, updatedAt every write).""" + """Append createdAt(once)/updatedAt audit stamps to a SET UpdateExpression. + + The stamps are SET assignments, so callers MUST stamp while the expression is still a pure + SET clause and append any ADD/REMOVE/DELETE clause AFTERWARDS — appending the stamps after an + ADD clause corrupts the expression (DynamoDB rejects ``... ADD s :r, createdAt = ...``). All + callers here pass pure-SET expressions. (Events-stack twin of register.py's ``_stamp``.) + """ values = dict(values) values[":now"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") return (