Security

plainfra access and revocation

What the plainfra read-only role can see in your AWS account, what it cannot, and how to remove it.

This is written for the person who approves the access rather than the person who installs it. Every claim below can be checked with a read-only command against your own account, and section 5 gives you those commands. Placeholders: ACCOUNT_ID (your 12-digit AWS account ID), EXTERNAL_ID (the value plainfra issues you), REGION (the region you deployed the stack in), OU_ID (the organisational unit the account sits in, or your organisation root ID if you have no organisational units, format r-xxxx).

The install creates exactly one resource: an IAM role named plainfra-ReadOnly. No buckets, no functions, no users, no other roles. Source of truth is the CloudFormation template itself, published at customer-role.yaml. Read it before you deploy it; we encourage that.

1. The off switch

Deleting the CloudFormation stack deletes the role. Run this in the account you want to remove, one command, no arguments to prepare:

aws cloudformation delete-stack --stack-name plainfra-ReadOnly --region REGION

For accounts installed through StackSets, remove the stack instance instead:

aws cloudformation delete-stack-instances --stack-set-name plainfra-readonly --deployment-targets OrganizationalUnitIds=OU_ID,Accounts=ACCOUNT_ID,AccountFilterType=INTERSECTION --regions REGION --no-retain-stacks --region REGION

The organisational unit and the account filter are both required here, even when you are removing a single account. A service-managed StackSet only accepts an organisational unit as its target, so passing Accounts= on its own is rejected with ValidationError: StackSets with SERVICE_MANAGED permission model can only have OrganizationalUnit as target. If your organisation is flat and has no organisational units, use the organisation root ID (format r-xxxx) as OU_ID, which is a valid target.

Either command returns immediately and the role is gone in about a minute. That is the complete removal: no residue, nothing to clean up afterwards, no coordination with plainfra required. You do not need to tell us, and you do not need our agreement.

Two things worth being straight about.

Disconnecting the account in the plainfra console does not revoke AWS access. It stops plainfra querying the account and frees the account ID for future reconnection, but the role and its trust policy stay exactly as they are, still assumable. plainfra simply stops calling sts:AssumeRole because it no longer has a reason to. If you want the access gone rather than unused, delete the stack. That is the only step that removes anything.

Cutting access without deleting the role takes two commands, not one. Blocking the trust policy stops new sessions being issued. It does not touch sessions that already exist, because AWS does not retroactively invalidate session tokens when a trust policy changes. Two bounds decide how long that gap lasts: role sessions are capped at one hour (MaxSessionDuration: 3600), and plainfra caches assumed-role credentials in a warm process for up to 45 minutes.

We have measured this rather than reasoned about it. With a deny-all trust policy applied to the role and a fresh sts:AssumeRole correctly refused within about eight seconds, a query against that same account still returned live data from cached credentials. If you use the trust-policy edit on its own as an incident stop, you have a window, and it can be tens of minutes long.

To stop access now, run both steps in this order.

First, block the trust policy:

aws iam update-assume-role-policy --role-name plainfra-ReadOnly --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}'

Then invalidate the sessions already issued, which is the step that closes the window. AWS provides this explicitly: an AWSRevokeOlderSessions inline policy denying everything issued before a given aws:TokenIssueTime. The IAM console exposes it as "Revoke active sessions" on the role, and the same policy can be attached with iam:PutRolePolicy (AWS documentation).

Neither step alone is sufficient. The first without the second leaves existing sessions working. The second without the first stops current sessions but lets a new one be issued straight away.

To restore access afterwards, put the original trust policy back:

aws iam update-assume-role-policy --role-name plainfra-ReadOnly --policy-document '{"Version":"2012-10-17","Statement":[{"Sid":"AllowPlainfraAssumeWithExternalId","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::121754142582:role/plainfra-lambda-execution"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"EXTERNAL_ID"}}}]}'

If you revoked sessions as well, remove the AWSRevokeOlderSessions inline policy from the role, or it keeps denying.

All of this leaves the CloudFormation stack in a drifted state, which is fine for an incident and wrong as a permanent removal. If you are finished with plainfra rather than pausing it, delete the stack instead. Deleting the role removes the thing sessions are issued against, so there is nothing left to revoke.

2. What the role can see

Every permission is listed explicitly in the template. No AWS managed policies are attached, and there is no *:* grant anywhere. The allow statements are Describe*, List* and Get* style calls across four groups:

Compute, containers, serverless. EC2, Elastic Load Balancing, Auto Scaling, Application Auto Scaling, ECS, EKS, ECR (repository, lifecycle and registry policy metadata), Lambda configuration and listings, App Runner.

Storage and databases. S3 bucket metadata and listings, EFS, AWS Backup, RDS, DynamoDB table metadata, ElastiCache, Redshift.

Identity, security, compliance. Six named IAM posture actions (iam:ListUsers, iam:GetUser, iam:GetAccountSummary, iam:GetAccountPasswordPolicy, iam:ListMFADevices, iam:ListAccessKeys) plus a named list of policy-document, role, group, user, instance-profile and federation-provider reads, CloudTrail (including LookupEvents), AWS Config, GuardDuty, Security Hub, IAM Access Analyzer, Systems Manager metadata and inventory, KMS metadata.

Networking, observability, cost, governance. CloudFront, Route 53 (including domains and resolver), ACM, WAFv2, CloudWatch, CloudWatch Logs metadata, X-Ray, SNS, SQS, EventBridge and Scheduler, Kinesis, Firehose, CloudFormation, Cost Explorer, Cost and Usage Reports, Budgets, AWS Organizations, Account, Trusted Advisor, AWS Health, resource tags.

Four things a reviewer usually expects to find here are deliberately absent or cut back, and that is the point. Secrets Manager is not granted at all, not even ListSecrets or DescribeSecret. API Gateway is not granted at all, because GetStage returns stage variables and GetExport returns a full API definition, and both routinely carry connection strings. Step Functions is not granted at all. IAM is a named list rather than iam:Get* and iam:List* wholesale; see section 4 for what that list covers and what it costs. Each of these was removed or narrowed rather than never added, so if you are comparing against an older copy of this page, that is why it is shorter.

No write, create, update, start, stop, or delete action is granted anywhere in the template. There is nothing in the role that can change your infrastructure.

Billing console access (aws-portal:ViewBilling, aws-portal:ViewUsage, billing:GetBillingData, billing:GetBillingDetails, billing:GetBillingNotifications, billing:GetBillingPreferences, billing:ListBillingViews) is off by default and only present if whoever deployed the stack set AllowBillingConsoleRead=true. Cost Explorer (ce:Get*) is in the base role either way, because it is where cost answers come from. Section 5 shows you how to check which setting was used.

3. What the role cannot see

A separate deny policy sits on top of the allow statements. In IAM, an explicit deny always wins, so these hold regardless of what a wildcard in section 2 technically matches. These are the exact statements, and they are the part worth checking yourself.

Data contents. s3:GetObject, s3:GetObjectVersion, s3:GetObjectTorrent, s3:GetObjectVersionTorrent, s3:SelectObjectContent, dynamodb:GetItem, dynamodb:BatchGetItem, dynamodb:Query, dynamodb:Scan, dynamodb:GetRecords, dynamodb:PartiQLSelect, kinesis:GetRecords, kinesis:GetShardIterator, kinesis:SubscribeToShard, sqs:ReceiveMessage, ecr:GetDownloadUrlForLayer, ecr:BatchGetImage, ec2:DescribeInstanceAttribute.

plainfra can see that a bucket exists, how big it is and how it is configured. It cannot read a file out of it. It can see a DynamoDB table's schema and item count. It cannot read a row. The last entry in that list is the odd one out and worth reading twice: ec2:DescribeInstanceAttribute is matched by the ec2:Describe* grant in section 2, and asked for the userData attribute it returns an instance's base64 bootstrap script, which is the most common place a credential ends up baked into infrastructure. It is denied outright rather than filtered.

Secrets, parameters, logs, decryption. secretsmanager:GetSecretValue, ssm:GetParameter, ssm:GetParameters, ssm:GetParametersByPath, ssm:GetParameterHistory, logs:GetLogEvents, logs:FilterLogEvents, logs:StartQuery, logs:StopQuery, logs:GetQueryResults, logs:StartLiveTail, kms:Decrypt, kms:GenerateDataKey, kms:GenerateDataKeyPair, kms:GenerateDataKeyWithoutPlaintext.

Log group names, retention settings and stored volume are visible. Log lines are not. Systems Manager parameter names are visible, their values are not, and Secrets Manager is out of reach entirely because section 2 grants it nothing to begin with. Nothing in the role can decrypt anything.

Code, execution, console access. lambda:InvokeFunction, lambda:InvokeFunctionUrl, lambda:GetFunction, lambda:GetLayerVersion, cloudformation:GetTemplate, states:GetExecutionHistory, ec2:GetConsoleOutput, ec2:GetConsoleScreenshot, ec2:GetPasswordData, rds-data:ExecuteStatement, rds-data:BatchExecuteStatement, glacier:GetJobOutput, glacier:InitiateJob.

plainfra cannot run anything. It cannot invoke a function, download a deployment package, fetch a CloudFormation template body, read EC2 console output or screenshots, retrieve a Windows password, or execute SQL through the RDS Data API.

API key values. apigateway:GET is not granted anywhere in the template, so nothing in API Gateway is readable at all. It is denied anyway against arn:aws:apigateway:*::/apikeys and arn:aws:apigateway:*::/apikeys/*, as a floor under any future decision to grant a narrower slice of API Gateway back.

4. The edges you should know about

Section 2 grants metadata. Some of that metadata is sensitive in its own right, and you will find this yourself in about twenty minutes of reading the template, so here it is up front.

  • Your user list, credential hygiene, and every IAM policy, role and group are readable. The posture actions return your IAM users, the account-level summary counts, which users have an MFA device registered, the account password policy, and the age and status of their access keys. That covers who has no MFA and whose key has not been rotated. The policy-document reads return the text of every managed and inline policy, every role and group and their permissions and trust relationships, and the SAML and OIDC federation configuration. No action returns a credential: a federation entry carries only the identity provider's public signing certificate and client identifiers, never a secret. It is still an enumerated list rather than iam:Get* and iam:List*, and every action's verb is Get, List or Describe. The honest cost: every role, policy and trust relationship in your account is, in effect, a map of how privilege can escalate from one principal to another, and this grant hands plainfra a readable copy of it. It is the same view your own IAM console shows, and it is what every cloud security posture tool reads, but it is still a map.
  • CloudTrail event history is readable. cloudtrail:LookupEvents returns management events: who did what, when, from which IP address. That is activity data about your staff, not just configuration.
  • Parameter names are readable even though values are not. The Systems Manager Describe* and List* calls expose parameter names, descriptions and tags, and names sometimes describe what the parameter is for. Secrets Manager is not granted at all, not even the metadata calls, so secret names and rotation settings are not readable either.
  • ECS task definitions can carry plaintext environment variables. ecs:Describe* includes DescribeTaskDefinition, which returns whatever you put in a container definition. If you set environment values inline rather than referencing Secrets Manager or Systems Manager, they are in that response. No IAM deny can scope to a field inside a task definition, so this one is handled in plainfra's code instead: environment variable values are withheld, credential-shaped command line arguments are masked, and sensitively named docker labels and log driver options are removed before the response is stored or shown. That is an application control, not a permission boundary, and you should weigh it as one.
  • CloudFormation stack parameters and outputs are readable. The template body itself is denied (cloudformation:GetTemplate), but Describe* returns parameters and outputs, which is where an endpoint or a connection string ends up when someone typed it in without NoEcho. plainfra withholds any parameter or output whose key name looks sensitive, and any parameter default the same way, before the data is stored or shown. Again an application control rather than a permission boundary.
  • S3 configuration is readable. Bucket names, policies, ACLs, encryption settings, lifecycle rules and public-access settings. Not object contents.
  • All resource tags are readable. tag:Get* covers every tag on every resource. Tags commonly carry owner names, email addresses, cost centres and project codenames.
  • Security findings are readable. GuardDuty, Security Hub, AWS Config and Access Analyzer results, including open findings. This is the current state of your security posture, including its gaps.

If any of those is unacceptable for a given account, the EC2-only variant (customer-role-lite.yaml, published alongside the full template) grants exactly one permission, ec2:Describe*, and nothing else. It carries the identical deny block described in section 3, so the userData denial above holds there too. It uses the same role name and trust policy, so moving from lite to full later is an in-place stack update rather than a fresh install.

5. Verifying all of this yourself

Every command here is read-only. Run them in the target account against your own role. None of them go through plainfra.

Confirm no AWS managed policies are attached, which is the fastest way to bound what the role can do:

aws iam list-attached-role-policies --role-name plainfra-ReadOnly

That should return an empty list. Then list the inline policies actually present:

aws iam list-role-policies --role-name plainfra-ReadOnly

Expect PlainfraInfrastructureDiscovery and PlainfraDataAccessDeny, plus PlainfraBillingConsoleRead only if billing console access was enabled at deploy time. Read the deny policy in full:

aws iam get-role-policy --role-name plainfra-ReadOnly --policy-name PlainfraDataAccessDeny

Check who can assume the role and under what condition:

aws iam get-role --role-name plainfra-ReadOnly --query 'Role.AssumeRolePolicyDocument'

Test the denies directly rather than reading them. IAM policy simulation evaluates the real policy set and tells you the effective decision per action:

aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::ACCOUNT_ID:role/plainfra-ReadOnly --action-names s3:GetObject secretsmanager:GetSecretValue logs:FilterLogEvents lambda:InvokeFunction lambda:GetLayerVersion kms:Decrypt cloudformation:GetTemplate ec2:DescribeInstanceAttribute apigateway:GET --output table

Every one of those should come back implicitDeny or explicitDeny. Add any action you want to satisfy yourself about.

Confirm which parameters the stack was actually deployed with, including whether billing console read was turned on. The command depends on how the role was installed, because a StackSet does not name its stacks the way a direct install does.

If the role was installed directly into this account, with CloudShell or the console quick-create, the stack is named plainfra-ReadOnly:

aws cloudformation describe-stacks --stack-name plainfra-ReadOnly --region REGION --query 'Stacks[0].Parameters'

If it was installed across your organisation with StackSets, that command fails with ValidationError: Stack with id plainfra-ReadOnly does not exist even though the role is present and healthy, because the stack is named StackSet-plainfra-readonly-<uuid>. Ask the StackSet for this account's stack instead, from the management or delegated administrator account:

aws cloudformation describe-stack-instance --stack-set-name plainfra-readonly --stack-instance-account ACCOUNT_ID --stack-instance-region REGION --region REGION

That returns the instance's StackId along with the parameter values in effect for this account. If you want the full stack view, run describe-stacks --stack-name against the returned StackId.

A failure here is a naming mismatch, not a security finding. Confirm the role itself with get-role and list-role-policies above before concluding anything is wrong.

After access has been in place for a while, audit what we actually called. Every plainfra session assumes the role with a session name prefixed plainfra-, and the specific names describe the purpose: plainfra-session for chat and reports, plainfra-collector for scheduled scans, plainfra-verify and plainfra-verify-account for connection checks, plainfra-upgrade-probe for a lite-to-full upgrade check. Those names appear in CloudTrail against every call:

aws cloudtrail lookup-events --lookup-attributes AttributeKey=ResourceName,AttributeValue=plainfra-ReadOnly --max-results 50 --region REGION

This is the check that matters most, because it is the only one that tells you what happened rather than what was permitted.

6. What you are actually trusting

Two things, and it is worth naming both.

The trust policy points at one named role inside plainfra's AWS account, not at the account as a whole. The principal is arn:aws:iam::121754142582:role/plainfra-lambda-execution, owned by Triont: the single service role plainfra uses to read your infrastructure. Only that role can assume your role, so a different principal in plainfra's account, even one permitted to call sts:AssumeRole generally, is not trusted here. An earlier version of this role trusted the whole account (:root); tightening it to the one role that actually assumes it narrows the trust boundary to that single service. You can confirm the exact principal at any time with the get-role command in section 5.

The External ID is the second factor, and it is one value per customer. The trust policy requires a matching sts:ExternalId, so knowing the role ARN alone is not enough to assume it. plainfra issues one External ID per customer and reuses it across every AWS account you connect, which is what allows a single StackSet parameter set to deploy everywhere. The consequence is that one value protects all your connected accounts. Treat it as a credential.

Both of these are bounded by section 3: even a successful assumption cannot read your data, your secrets, your logs, or your code, and cannot change anything.

Questions on any of this: support@plainfra.com, or ask Tim directly.