Reduced Analytics Platform Costs Using Parameterized Configurations
Or: How We Discovered That Per-User Billing Is a Feature, Not a Bug — For Your Cloud Provider
A guide for those who implemented Row-Level Security in an analytics platform, celebrated briefly, received the invoice, and then quietly dismantled everything they’d just built.
What Is This Analytics Platform and Why Its Pricing Model Warrants Discussion
The platform in question is a cloud-based business intelligence service. It produces dashboards. You embed them in applications. People look at charts. Business decisions happen, theoretically.
The service works well. The pricing model is also perfectly coherent if you are the cloud provider. For everyone else, it is the kind of thing you read once, assume you’ve misunderstood, read again, and then accept.
The platform bills per registered user. Authors cost $24/month. Readers vary by usage. In an application where every seller is supposed to see their own dashboard, the natural instinct is to register each seller as a platform user with row-level security. This instinct is correct from a security architecture standpoint and expensive from every other standpoint.
This post documents how we arrived at a different approach: one service account user, URL-based parameters for data filtering, and an invoice that no longer requires a separate line item in the budget meeting.
Prologue: The Row-Level Security Phase
The original implementation was architecturally correct. Each seller would be a registered platform user. Row-Level Security would ensure sellers only saw their own data. The platform would enforce access at the service level. It was clean, native, and the obvious solution.
The evidence of this era survives in commented-out code — a complete user registration flow that auto-provisioned a platform account for each seller on their first dashboard load:
# if not user_exists:
# print('User does not exist, creating user in the platform')
# params = {
# 'AwsAccountId': str(aws_account_id),
# 'Namespace': 'default',
# 'IdentityType': 'PLATFORM_USER',
# 'UserName': user_name,
# 'UserRole': 'READER',
# 'Email': 'placeholder@example.com', # temporary dummy email
# }
# response = platform_client.register_user(**params)
Note the placeholder@example.com. At some point, creating users required an email address the platform could send things to, but we didn’t want sellers receiving platform welcome emails. So we used a placeholder. This is the kind of workaround that accumulates when you’re building on top of a system that wasn’t designed with your use case in mind.
The RLS implementation worked. The user creation worked. The dashboards loaded with correctly filtered data. Then someone looked at the projected monthly cost with 50+ sellers, did arithmetic, and the architectural review began.
The conclusion, reached after a period of reflection that could charitably be described as brief: the security model needed to move out of the platform and into the application layer.
The Parameterized Approach
The insight that unlocked everything was already documented in the platform’s own developer guide, which is where it had been the entire time.
The platform’s embed URLs support URL parameters. You append them after a # fragment:
https://platform.example.com/embed/...#p.parameterName=value
These parameters map to platform parameters defined in the dashboard. Filters attached to those parameters update the visualizations accordingly. The dashboard shows only what the parameter specifies.
The result: one registered platform user, one embed URL generation per request, and data filtering handled by whoever calls the API — which is the backend service, which already knows who the authenticated user is.
Implementation
Backend: One Pattern, Applied Consistently
The backend exposes several endpoints for generating analytics embed URLs — one for the seller’s overview dashboard, one for a specific entity’s analytics, and a separate one for admins. Three of them follow the same pattern with minor variations. The admin endpoint is different by design: admins retain their own platform accounts because the admin dashboard shows all sellers’ data and doesn’t need filtering.
The core pattern — seller dashboard:
response = platform_client.generate_embed_url_for_registered_user(
AwsAccountId=aws_account_id,
ExperienceConfiguration={
'Dashboard': {
'InitialDashboardId': dashboard_id
}
},
SessionLifetimeInMinutes=60,
UserArn=f"arn:aws:platform:eu-west-2:{aws_account_id}:user/default/{shared_service_account}"
)
embed_url = response['EmbedUrl']
final_embed_url = f'{embed_url}#p.identifier={seller_identifier}'
shared_service_account is the single shared service account, pulled from an environment variable. The seller’s identifier — extracted from their authenticated token — is appended as p.identifier. The platform dashboard has a parameter named identifier with a filter applied to it. Sellers see their data. No per-seller platform account required.
Entity-specific dashboard:
final_embed_url = f'{embed_url}#p.identifier={seller_identifier}&p.entity_id={entity_id}&p.database_reference={database_ref}'
This one passes three parameters: the seller’s identifier, the entity’s primary ID format, and the entity’s database reference format. Both ID formats exist because the entity data has references in both forms depending on which collection you’re querying. This is the kind of thing that doesn’t come up until you’re debugging why some charts filter correctly and others don’t.
Admin viewing a specific seller’s entity:
# The admin's own account is used here, not the shared service account
final_embed_url = f'{embed_url}#p.identifier={seller_identifier}&p.entity_id={entity_id}&p.database_reference={database_ref}'
Admins use their own platform accounts because admins need the platform’s full feature set — bookmarks, drill-downs, export. Routing them through the shared service account would mean everyone shares one session state. This is technically possible and experientially terrible. The cost calculation also works out differently when the user count is fixed at a small number regardless of how many sellers exist.
Cross-Account Access in Non-Production Environments
One non-obvious detail: in development and pre-production, the platform account lives in a different AWS account than where the backend services run. This requires STS role assumption before making platform API calls:
if stage in ('dev', 'pre-production'):
sts_client = boto3.client('sts')
assumed_role = sts_client.assume_role(
RoleArn=os.environ.get('PLATFORM_ASSUME_ROLE_ARN'),
RoleSessionName='PlatformEmbedSession'
)
credentials = assumed_role['Credentials']
session = boto3.Session(
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken'],
region_name=os.environ['REGION']
)
else:
session = boto3.Session()
Production runs in the same account as the platform, so a default session suffices. The branching is inelegant but explicit, which is the correct trade-off when debugging cross-account permission issues at midnight.
Frontend: An iframe
<iframe
title="Analytics Dashboard"
width="100%"
height="100%"
src={dashboardUrl}
frameBorder="0"
allowFullScreen
/>
The frontend requests the embed URL from the backend, sets it as the iframe src, and renders a loading state while waiting. The platform’s JavaScript SDK was evaluated and found to offer additional complexity without measurable benefit for this use case. The iframe loads. The dashboard appears. Users view their data.
What the Platform Side Needs
For URL parameters to work, the platform dashboard must be configured to match. This is entirely in the platform console and involves:
- Creating parameters — named to match what the backend appends (e.g.,
identifier,entity_id,database_reference) - Creating filters on each visual — bound to the parameters, filtering the relevant columns
- Setting default values — parameters without defaults cause dashboards to load unfiltered, which is the opposite of the goal
The parameter names in the URL must exactly match the parameter names in the dashboard. Case sensitivity applies. This is the kind of thing you discover by staring at a fully unfiltered dashboard for longer than is comfortable.
Cost Comparison
The original model, reconstructed:
| Item | Count | Monthly Cost |
|---|---|---|
| Seller platform users (Reader) | 50+ | Variable, scales with sellers |
| Admin platform users (Author) | 3 | $72 |
| User management overhead | Ongoing | Unquantifiable but real |
The current model:
| Item | Count | Monthly Cost |
|---|---|---|
| Shared service account (Author) | 1 | $24 |
| Admin platform users (Author) | 3 | $72 |
| User management overhead | None | $0 |
As the seller base grows, the original model scales linearly in cost. The current model does not. This is the entire argument for the approach, stated plainly.
Security Trade-offs, Stated Without Editorializing
In the original model, the platform enforced data isolation. A misconfigured parameter would result in an empty dashboard, not a data leak.
In the current model, the application layer enforces data isolation. The backend reads the seller’s identity from their authenticated token and passes it as the filter parameter. If there is a bug that allows parameter tampering, data isolation breaks.
This is a real trade-off. It was accepted because:
- The backend is already the authentication boundary for all other API endpoints
- Token extraction is consistent across all dashboard handlers
- The alternative cost was not acceptable
Whether this trade-off is correct for a given application depends on the application’s risk profile. For this one, it was deemed acceptable. Your mileage may vary, and your security team’s opinion on the matter is more relevant than this document.
Lessons Learned
Read the pricing page before building the security model. The platform’s per-user pricing is not hidden. It is, in fact, the first thing on the pricing page. The correct time to evaluate it is before the architecture decision, not after a successful implementation.
URL parameters are a first-class platform feature, not a workaround. The platform documentation explicitly documents parameterized embedding. It was not discovered through creative misuse of the API. It was simply read later than optimal.
When you pass multiple ID formats for the same entity, that is a data consistency problem wearing a dashboard problem costume. If the same entity needs to be referenced by both a primary ID and a database reference depending on which collection you’re querying, the dashboard parameter is the symptom. Fixing the symptom is faster than fixing the cause, which is why the symptom gets fixed first and the cause gets documented here.
Keep admin users separate. Admins need the platform’s full feature set. Routing them through a shared service account means everyone shares session state. This is technically possible and experientially terrible.
Troubleshooting
Dashboard loads but shows all data, not filtered data: The parameter name in the URL does not match the parameter name defined in the platform dashboard. Check case sensitivity. Check spelling. Then check both again.
ResourceNotFoundException when generating the embed URL: The UserArn references a platform user that doesn’t exist in the specified namespace. Either the shared service account hasn’t been created, or an admin account hasn’t been registered. Platform users are account-specific and region-specific — a user that exists in one region does not exist in another.
Cross-account permission failures in non-production environments: The IAM role used for STS assumption needs two things: a trust policy allowing the backend execution role to assume it, and a permissions policy allowing platform API calls. Both are required. The error messages from STS and the platform distinguish between these two cases if you read them carefully.
Session expiry complaints from users: The 60-minute session lifetime is conservative. The platform supports up to 600 minutes. Whether extending it is appropriate depends on the application’s session management policy, but it is a one-line change if the answer is yes.
Epilogue
The final state of the implementation is simpler than the initial design. One service account. A handful of URL-generation endpoints that are variations on one pattern. An iframe. URL parameters that map to dashboard filters.
The commented-out user registration code remains as documentation of the path not taken. It could be deleted. It hasn’t been. Partially because deletion requires a decision, and partially because it accurately captures a moment where the implementation was technically correct and financially unsustainable simultaneously — which is a state worth preserving for future reference.
The analytics platform, for what it’s worth, works well once configured. The dashboards load. The data filters. The cost is predictable. These are not exciting outcomes, but they are the correct ones.