Security¶
LSO runs playbooks and executables on the machine it sits on. Whatever its process can reach, a request can reach. This page describes what LSO enforces, what it does not, and how to make it enforce more.
The default: LSO enforces nothing¶
An LSO that you have not configured for this identifies no caller and allows every request. Access control is entirely a matter of where you put it: a trusted network, or an authenticating reverse proxy in front.
That is a legitimate way to run LSO and it stays supported. Upgrading does not change it. Everything below is a capability you switch on, never a new requirement.
Application-level authentication is not a substitute for network placement
Even with every request authenticated and authorized, LSO still starts processes on the host. Keep it off untrusted networks regardless.
Network security and application security solve different problems¶
A reverse proxy can decide whether a caller may reach LSO at all. It is generally poor at deciding what that caller may then do, because it sees a URL and a header, not a request in context.
LSO’s own authorization sees the whole request. It can allow /api/version while refusing /api/execute/,
or allow a caller to list one job’s files but not delete another’s. Those endpoints do not carry equal risk,
and a policy inside LSO is the only place that distinction can be drawn.
The two combine well. Neither replaces the other.
Switching it on¶
Two settings control it, and both are off by default.
| Variable | Meaning |
|---|---|
LSO_OAUTH2_ACTIVE |
Whether LSO identifies its callers. |
LSO_OAUTH2_AUTHORIZATION_ACTIVE |
Whether LSO applies a policy to each request. Requires the above. |
LSO_API_KEY |
A shared secret callers present as a bearer token. |
Why these names carry an LSO_ prefix
Services in one deployment often share a single .env. The prefix keeps LSO’s switches separate from an
OAUTH2_ACTIVE that belongs to another application.
OIDC and OPA connection details describe a provider, not LSO, so they keep the names oauth2-lib gives
them: OIDC_CONF_URL, OPA_URL and the rest.
The quickest way: a shared secret¶
Most LSO deployments have one client calling LSO. A full OIDC provider is too much for that. A shared secret is enough: one long random value that both sides know. Set two variables and write no code:
LSO_OAUTH2_ACTIVE=true
LSO_API_KEY=a-long-random-value
Callers then send the secret as a bearer token:
curl -H "Authorization: Bearer a-long-random-value" https://lso.example.org/api/version
A request with no token, or with the wrong one, gets a 401. Both answers are the same, so a caller cannot
tell whether a secret is set at all.
This works with the stock container image. At startup, LSO reads LSO_API_KEY and sets up the check itself.
Rotating the secret
LSO_API_KEY holds one secret. If you change it, LSO rejects the old one as soon as it restarts. Every
caller still sending the old one gets a 401 until it is updated too.
To avoid that gap, accept both secrets while you move the callers across:
from lso.app import create_app
from lso.auth import SharedSecretAuth
app = create_app()
app.register_authentication(SharedSecretAuth(["old-secret", "new-secret"]))
When every caller sends the new secret, restart with only the new one.
An environment variable cannot hold a list, so this needs your own module. See Registering your own implementation.
If a short break is fine, skip all this: change LSO_API_KEY and restart LSO and its callers.
What happens for each combination¶
LSO refuses to start rather than boot into a bypass it would not report. A deployment that asked for authentication and silently got none is worse than one that never asked, because it believes it is protected.
LSO_OAUTH2_ACTIVE |
LSO_OAUTH2_AUTHORIZATION_ACTIVE |
Registered | Result |
|---|---|---|---|
| false | false | anything | No checks: every request goes through. The default. |
| true | false | nothing, LSO_API_KEY set |
Shared secret, registered automatically. |
| true | false | authentication | Callers are identified. No policy. |
| true | true | authentication and authorization | Callers are identified and judged. |
| true | any | nothing, no LSO_API_KEY |
Refuses to start. |
| false | true | anything | Refuses to start: a policy has no caller to judge. |
| true | true | authentication only | Refuses to start: no policy to apply. |
Each refusal names what to set or call.
Registering an implementation and leaving the switch off is the opposite mistake, and LSO allows everything in that state: the switch is the authority. It is not treated as an error, because running one image across environments and switching by variable is a fair way to work, but LSO logs a warning at startup so the mistake is not silent.
Authentication and authorization are separate¶
Three things happen, in order.
- Token extraction. LSO finds the token in the request. By default it reads
Authorization: Bearer <token>. A missing token is not an error at this point. - Authentication. Your
Authenticationturns the request and that token into a caller. - Authorization. Your
Authorizationdecides whether that caller may make this particular request.
| Stage | Outcome | Result |
|---|---|---|
| Authentication | returns a user | carries on to authorization |
| Authentication | returns None |
carries on with no caller identified |
| Authentication | raises HTTPException(401) |
401, request stops |
| Authorization | returns True |
allowed |
| Authorization | returns False |
403, request stops |
| Authorization | returns None |
allowed: no opinion was expressed |
| Authorization | raises HTTPException |
that response, so you can set your own detail |
None is not a denial
An authorization returning None means it had no opinion, and the request proceeds. Only False
denies.
A denied request is rejected before its body is validated, so a caller who may not use an endpoint does not learn what that endpoint’s body should look like.
Writing your own implementation¶
Subclass Authentication and Authorization from lso.auth:
import os
from lso.auth import Authentication, Authorization
EXPECTED_TOKEN = os.environ["MY_CLIENT_TOKEN"]
class HeaderAuthentication(Authentication):
async def authenticate(self, request, token=None):
if token != EXPECTED_TOKEN:
return None
return {"groups": ["operators"], "sub": "my-client"}
class ReadOnlyForMostCallers(Authorization):
async def authorize(self, request, user):
if request.url.path.startswith(("/api/execute", "/api/playbook")):
groups = (user or {}).get("groups") or []
return "operators" in groups
return True
authorize receives the whole request, so request.url.path, request.method, request.path_params and
request.query_params are all available. Reading the body with await request.json() works too, and does
not stop the endpoint parsing it afterwards.
Rules per playbook or per inventory¶
These belong in your policy. The body holds playbook_name, inventory and extra_vars, so the policy can
decide on any of them. The OPA adapter sends the body to OPA as input.arguments.json.
ALLOWED = {"backup.yaml": {"operators"}}
class PerPlaybook(Authorization):
async def authorize(self, request, user):
if request.url.path != "/api/playbook/":
return True
body = await request.json()
groups = set((user or {}).get("groups") or [])
return bool(groups & ALLOWED.get(body.get("playbook_name"), set()))
Two things to watch:
- Match the playbook name exactly, not by prefix.
..goes back one folder. Sook/../danger.yamlstarts withok/, but LSO resolves it and runsdanger.yaml. A rule likename.startswith("ok/")would allow it. Compare the whole name against a list instead. extra_varscan change which hosts a playbook targets, for examplehosts: "". A rule about hosts must checkextra_varstoo, not onlyinventory.
Taking the token from another header¶
By default LSO reads the token from Authorization: Bearer <token>. If your callers send it somewhere else,
write an extractor:
from lso.auth import IdTokenExtractor
class ApiKeyHeaderExtractor(IdTokenExtractor):
async def extract(self, request):
return request.headers.get("X-API-Key")
Return None when there is no token. Do not reject the request in the extractor: that is the job of your
Authentication.
This also moves the shared secret. By default LSO_API_KEY is sent as Authorization: Bearer <secret>.
With the extractor above, the same secret is sent as X-API-Key: <secret> instead.
Registering your own implementation¶
Build the application yourself and register on it, then serve that module:
from lso.app import create_app
from my_auth import ApiKeyHeaderExtractor, HeaderAuthentication, ReadOnlyForMostCallers
app = create_app()
app.register_extractor(ApiKeyHeaderExtractor())
app.register_authentication(HeaderAuthentication())
app.register_authorization(ReadOnlyForMostCallers())
uvicorn my_lso:app --host 0.0.0.0 --port 8000
register_extractor is optional. Leave it out to keep the bearer header.
Why your own module¶
lso.app:app is already built when uvicorn imports it, so there is no point at which your code could
register on it. That is why you build the application yourself.
The example Dockerfile serves lso.app:app, so copy your module in and change the command to serve it
instead. A shared secret needs none of this: LSO_API_KEY works with the stock image.
OpenID Connect and Open Policy Agent¶
These come through oauth2-lib, as an optional extra:
pip install 'orchestrator-lso[oidc]'
It is not a core dependency. oauth2-lib brings authlib, structlog, asyncstdlib and
strawberry-graphql with it; LSO has no GraphQL and being small is the point of it, so that is not a price
to put on every deployment. Nothing in LSO imports it unless you do.
from lso.app import create_app
from lso.oidc import oidc_authentication, opa_authorization
app = create_app()
app.register_authentication(oidc_authentication())
app.register_authorization(opa_authorization())
Provider settings keep oauth2-lib’s own names: OIDC_CONF_URL, OIDC_BASE_URL,
OAUTH2_RESOURCE_SERVER_ID, OAUTH2_RESOURCE_SERVER_SECRET and OPA_URL.
LSO_OAUTH2_ACTIVE is still the only switch
oauth2-lib has switches of its own, OAUTH2_ACTIVE and OAUTH2_AUTHORIZATION_ACTIVE. When they are
false, it allows every request. LSO sets them from its own settings when you build these adapters, so you
never set them yourself and they cannot disagree.
This matters when LSO shares an .env file with another application. An OAUTH2_ACTIVE=false left there
for that application would otherwise switch LSO’s OIDC off, and LSO would allow everything while looking
protected.
Which AsyncClient to import
OIDCAuth.userinfo is not implemented for you: subclass OIDCAuth and write it for your provider. The
client it receives comes from oauth2-lib, which on 3.x uses httpx2, the same client LSO uses. Import
AsyncClient, BasicAuth, Timeout and Limits from httpx2, not from the older httpx: they are
unrelated classes that happen to share their names.
What is not covered¶
- The API documentation endpoints.
/api/doc,/api/redocand/api/openapi.jsonstay readable whatever your policy decides, because FastAPI serves them outside the dependency system. This is accepted rather than pending: LSO’s API schema is published with the project, so serving it reveals nothing that is not already on the project website. Block them at your reverse proxy if your deployment would rather not serve them at all. - Paths that do not exist. A request to an unknown URL is answered with a
404before any policy runs, so a caller can discover which endpoints exist.
Upgrading¶
Nothing to do. An LSO that sets none of these variables behaves exactly as it did before.