Govern agent access with Lumos

Manage who can use your agents from Lumos, with your own approvals and access reviews
View as Markdown

Lumos manages entitlements: who may have access to which application, with requests, approvals, time limits, and periodic reviews. Connect it to Astropods and it manages the membership of your audiences, so agent access follows the same approval path as the rest of your stack.

Lumos never sits in the request path. It writes membership ahead of time, and Astropods decides each request on its own.

This page covers both halves: creating the credential Lumos authenticates with, then building the connector that carries membership across. The end state is a connector that reads your audiences into the Lumos catalog and writes membership when an access request is approved.

Before you start

  • An audience for each group you want to govern. Create them first, because Lumos manages who is on a list rather than creating the lists. See Audiences.
  • The account administrator or maintainer role, which is what lets you create an OAuth app. Organization and personal accounts both support them.
  • Lumos’s Connector SDK, and a Python environment to run it in. The connector runs in the Lumos cloud or on your own infrastructure.
pip install "connector-py[dev]"

How the two models line up

Lumos uses the word “account” for a person’s identity inside an application. Astropods uses it for the tenant. This page always means the tenant.

Lumos conceptAstropodsIdentifier the connector sends
Application instanceOne Astropods accountThe account slug
AccountA person’s Astropods identityTheir user ID
ResourceAn audienceThe audience ID
EntitlementMembership of that audienceThe fixed ID member

Astropods has no second entitlement kind, so the connector synthesizes one member entitlement per audience rather than reading a list of them. Agents are not resources, so deploying or deleting one never changes the Lumos catalog: only audiences are governed, and the catalog stays stable while your agents change.

An OAuth app belongs to one Astropods account, so configure one Lumos application instance per account.


Create the OAuth app

An OAuth app is a credential that belongs to the account rather than to a person, so it keeps working after its creator’s own access changes.

1

Open the OAuth apps page

For an organization, go to Settings, then your organization, then OAuth Apps. For a personal account, go to Settings > OAuth Apps.

2

Create the app

Name it for the integration, such as lumos, and select its scopes. See Scopes for the three a membership sync needs.

3

Copy the client ID and secret

The secret is shown once. Store it wherever your connector reads its configuration, because Astropods cannot show it again.

An app holds up to five secrets, and none of them expire. To rotate without downtime, add a second secret, move the connector onto it, then revoke the first. Revoking the last secret is refused, so rotation never leaves an app with no way in. Each secret shows when it was last used, which is how you tell which one the connector is still on.

Deleting the app revokes its access at once, including tokens that have not expired yet.

Get an access token

Exchange the client ID and secret for an access token using the OAuth 2.0 client credentials grant:

curl -X POST https://login.astropods.com/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d client_id=$ASTRO_CLIENT_ID \
-d client_secret=$ASTRO_CLIENT_SECRET
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600
}

Send the token on every request as Authorization: Bearer <access_token>, and request a new one when it expires. The API is at https://astropods.com/api/v1.

Scopes

An app holds the scopes you select, and Astropods reads them from the app on every request. A scope change therefore applies to the next call rather than at the next expiry.

ScopeAllows
member:readRead the account’s member list, which is the directory you correlate people against.
audience:readRead audiences and their membership.
audience:manage_membersAdd and remove audience members.

Select only what the connector uses. Reading people and reading audiences are separate scopes, and a membership sync needs both, because it correlates people from the member list before it writes to an audience.

A scope applies to every audience in the account, not to particular ones. To stop a connector managing membership, remove audience:manage_members from the app. An app with no scopes still authenticates and is refused everywhere, which suspends an integration without destroying its credential.

A call that needs a scope the app does not hold returns 403, because the resource exists and the credential is the thing that falls short.

Check the credential by hand

Before building anything, confirm the app can read and write. GET /api/v1/me returns the account slug every other path contains, along with the app’s own scopes:

{
"app": { "id": "...", "client_id": "...", "name": "lumos", "scopes": ["member:read", "audience:read", "audience:manage_members"] },
"accounts": [{ "id": "...", "name": "acme", "type": "organization", "display_name": "Acme" }]
}

An app is bound to one account, so accounts holds exactly one entry and accounts[0].name is the slug.

Then assign someone:

curl -X POST https://astropods.com/api/v1/accounts/acme/audiences/aud_123/members \
-H "Authorization: Bearer $ASTRO_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": "user_01H...", "external_id": "lumos-assignment-42"}'

external_id is the assignment’s identifier in Lumos. It is optional, and Astropods returns it on every read of that membership, so a review can trace a grant back to the request that produced it. One external_id names one membership: reusing it for a different person or audience returns 409.

ResponseMeaningWhat the connector should do
200The person is a member. A replayed assignment returns 200 too.Treat a retry after a timeout as normal traffic.
400Neither user_id nor account was sent.Fail.
403The app lacks the scope the route needs.Fail, and surface the scope. Nothing in Lumos can grant it.
404The account handle matches nobody on Astropods.Fail the assignment.
409That person has no Astropods user yet, or external_id already names another membership.Retry the first on the next sync. Fail the second, because retrying cannot fix it.
422user_id holds an email address instead of a user ID.Fix the mapping: correlate on the handle, write the ID.

Scaffold the connector

connector scaffold astro-connector astro_connector \
--author-name "Your Name" --author-email "you@example.com"

Five of the generated files carry this integration:

FileWhat you put in it
constants.pyThe API base URL, the token URL, and the credential ID enum
enums.pyThe audience resource type and the member entitlement type
auth.pyThe client-credentials credential
client.pyThe Astropods HTTP client
capabilities_read.py, capabilities_write.pyThe capability bodies

Declare the credential

Astropods issues OAuth 2.0 client credentials, so the connector asks Lumos for a client ID and secret and exchanges them itself:

astro_connector/constants.py
class AstroConnectorCredentialId(str, Enum):
"""IDs of the credentials this connector accepts."""
OAUTH_CLIENT_CREDENTIALS = "astro_oauth_client_credentials"
API_BASE_URL = "https://astropods.com/api/v1"
TOKEN_URL = "https://login.astropods.com/oauth2/token"
astro_connector/auth.py
AstroCredentialsConfig = [
CredentialConfig(
id=AstroConnectorCredentialId.OAUTH_CLIENT_CREDENTIALS,
name="Astropods OAuth app",
type=AuthModel.OAUTH_CLIENT_CREDENTIALS,
description=OAUTH_DESCRIPTION,
oauth_settings=OAuthConfig(
flow_type=OAuthFlowType.CLIENT_CREDENTIALS,
token_url=TOKEN_URL,
scopes=["member:read", "audience:read", "audience:manage_members"],
),
),
]

The credential ID is part of the connector’s public contract. Renaming it later invalidates every existing connection, so choose it once.

Lumos maps scopes to capabilities in oauth_settings, which is what stops a connector that never writes from holding a write scope. See Lumos’s authorization reference for the mapping syntax.

Write the client

The SDK hands each capability an OAuthClientCredential carrying an access_token, so the client sends it as a bearer token and does not handle the exchange itself:

astro_connector/client.py
class AstroClient:
def __init__(self, credential: OAuthClientCredential):
self._headers = {"Authorization": f"Bearer {credential.access_token}"}
self._account: str | None = None
async def account(self) -> str:
"""The account slug every other path contains."""
if self._account is None:
me = await self._get("/me")
self._account = me["accounts"][0]["name"]
return self._account

Register the capabilities

astro_connector/integration.py
integration.register_capabilities(
{
StandardCapabilityName.VALIDATE_CREDENTIALS: capabilities_read.validate_credentials,
StandardCapabilityName.LIST_ACCOUNTS: capabilities_read.list_accounts,
StandardCapabilityName.LIST_RESOURCES: capabilities_read.list_resources,
StandardCapabilityName.LIST_ENTITLEMENTS: capabilities_read.list_entitlements,
StandardCapabilityName.FIND_ENTITLEMENT_ASSOCIATIONS: capabilities_read.find_entitlement_associations,
StandardCapabilityName.ASSIGN_ENTITLEMENT: capabilities_write.assign_entitlement,
StandardCapabilityName.UNASSIGN_ENTITLEMENT: capabilities_write.unassign_entitlement,
}
)

Each one is backed by a single endpoint:

CapabilityEndpoint
validate_credentialsGET /me
list_accountsGET /accounts/{account}/members
list_resourcesGET /accounts/{account}/audiences
list_entitlementsNone. Synthesize one member entitlement per audience.
find_entitlement_associationsGET /accounts/{account}/audience-members
assign_entitlementPOST /accounts/{account}/audiences/{audience_id}/members
unassign_entitlementDELETE /accounts/{account}/audiences/{audience_id}/members/{user_id}

Implement the read capabilities

Validate the credential

GET /me is the cheapest authenticated call, and it returns the app’s own scopes. Check them here so a missing scope surfaces when someone connects the app rather than halfway through the first sync.

astro_connector/capabilities_read.py
async def validate_credentials(
args: ValidateCredentialsRequest,
) -> ValidateCredentialsResponse:
me = await client(args).get("/me")
missing = REQUIRED_SCOPES - set(me["app"]["scopes"])
...

List accounts

The member list is the directory you correlate people against. Map each member to FoundAccountData:

FoundAccountDataAstropods field
integration_specific_iduser_id
usernameusername, the person’s Astropods handle
emailemail, when present
user_statusACTIVE, or PENDING for an invited member
account_typeuser

Correlate people to your directory by handle, not by email. Astropods returns email only for a member who has not set up a profile yet, so it is absent for most of the list. Write user_id once you have matched someone, because handles change and IDs do not. If you hold only a handle, send account instead of user_id and Astropods resolves it.

List resources and entitlements

GET /accounts/{account}/audiences returns the lists. Map each to FoundResourceData with integration_specific_id set to the audience ID, label to its name, and resource_type to audience. The response also carries an arn and a member_count, which are useful in extra_data for a review.

list_entitlements calls nothing. For each audience in the request, return one entitlement whose ID is member, typed member. An audience has no other kind of membership, so a second entitlement would describe nothing.

Find associations

GET /accounts/{account}/audience-members is a flat collection of every (audience, person) pair in the account, which is exactly the shape this capability returns:

FoundEntitlementAssociation(
account_id=row["user_id"],
integration_specific_entitlement_id="member",
integration_specific_resource_id=row["audience_id"],
)

Read the flat collection rather than each audience’s own member list. Per-audience reads cost one call per audience on every sync, and this endpoint exists to collapse them into one paged walk.

Include expired rows. Astropods marks them with expired: true instead of hiding them, so a review can show what lapsed.

Implement the write capabilities

An assignment is one POST per person. No endpoint accepts a list of members, because retrying, overlapping calls are only safe against per-person writes.

astro_connector/capabilities_write.py
async def assign_entitlement(
args: AssignEntitlementRequest,
) -> AssignEntitlementResponse:
request = args.request
await client(args).post(
f"/accounts/{account}/audiences/{request.resource_integration_specific_id}/members",
json={
"user_id": request.account_integration_specific_id,
"external_id": external_id(args),
},
)
return AssignEntitlementResponse(response=AssignedEntitlement(assigned=True))

Send expires_at as well when Lumos grants time-bound access. See Time limits.

unassign_entitlement is the matching DELETE, and it removes only the row your app wrote. A membership belongs to the writer that created it, so an identical grant an administrator added by hand survives your removal. To end access entirely, remove it in both places.

Every write is attributed to the app, so the audit log names the app rather than the person who created the credential.

Handle pagination

The audience endpoints take limit and an opaque cursor, and return next_cursor until the last page, so a review reads the whole set without gaps. Carry the Astropods cursor in the Lumos page token:

page=Page(token=body.get("next_cursor"), size=page_size) if body.get("next_cursor") else None

A None token is how Lumos knows to stop calling, so return it as soon as next_cursor is absent. When one capability walks more than one upstream cursor, use the SDK’s pagination helpers instead of a bare token.

The member list is not paged and returns every member in one response.


Time limits

Set expires_at when Lumos grants time-bound access and Astropods enforces it: an expired membership stops granting access within a minute, whether or not Lumos calls back to remove it. Lumos removing the assignment stays the normal path.

An expired membership still appears when the connector reads membership, with expired set to true, so a review shows what lapsed rather than hiding it.

Slack identity

An audience is a list of people, so a Slack request has to be matched to a person before the list applies. People link their own Slack account from their settings.

What Lumos does not manage

  • Creating and deleting people. Your identity provider owns who exists. Deprovision by removing the audience membership, not by editing an Astropods user. Astropods never creates users, so invite people through your identity provider as usual.
  • Creating audiences. Create the lists in Astropods, then govern their membership from Lumos.
  • Linking Slack accounts. Each person links their own, so a connector never writes it.
  • Platform permissions. Audiences control who can talk to an agent. Who may deploy or reconfigure one is a separate setting, covered in Access control.
  • Usage reporting. Astropods does not report a last-used time per person and agent, so Lumos cannot flag unused agent access during a review.

Next steps