beps/0007-auth-external-services/README.md
This proposal builds upon the foundation laid out in the Auth Architecture Evolution BEP, by improving the authentication mechanisms used by external callers seeking to access the REST APIs exposed by Backstage plugins.
Specifically, the inclusion of shared-key based authentication is proposed, along with a framework for integrating different authentication types in the future. Compatibility with the legacy shared-secret token signature mechanism is retained.
By implementing this proposed solution, security should not be compromised beyond the existing state when exposing the API to the internet. Nonetheless, it is advisable to maintain a level of protection by shielding your deployments from the internet access unless you explicitly need public access.
This proposal represents a progression from the old service to service auth tutorial in the context of the new coreServices.auth and coreServices.httpAuth authentication services.
It offers expanded configuration possibilities and will include ready-to-use examples as part of the implementation to streamline the process of invoking REST services exposed by Backstage backend plugins for external services.
The previous tutorial lacked clarity on how to effectively structure the call, resulting in a cumbersome experience.
Examples of potential use-cases include:
curl friendly tokens in local developmentTo facilitate this process, no code change but configuration only is needed on the Backstage side.
It is not a goal
The backend.auth.keys section of your app config remains supported for the time being, but is considered legacy. It is read by the new auth handler code solely for backward compatibility reasons. Using this format will lead to a logged warning urging a migration to the new format described below.
This BEP adds a backend.auth.externalAccess section to your app config. This section is the new recommended way of declaring all methods of external service access that you want to support. Like the legacy section above, it is an array, but each element has a type field to allow for extensibility. Using an array also means that the configuration system will not make any config merging of access methods across different config files - which avoids unintended mistakes that might otherwise have impacted security.
The two types of external access covered by this BEP are legacy and static. More on that below.
The following is an example of what an app config section with a single access method could look like:
backend:
auth:
externalAccess:
- type: static
options:
token: ${SERVICE_API_TOKEN}
scope:
plugins: catalog
Each entry has a type, which is any string. The framework handles a number of these types - initially a fixed set, but in the future we might make it extensible.
The options is a general object, and is specific to each type, configuring it as needed. This particular type only had a token, which was configured to be taken from an environment variable.
Each entry may also optionally have a scope section that controls what operations that this entry can be used for. Attempting to use this access method for any other scope will lead to 403 error rejections. Scopes can contain plugin IDs and/or permissions.
Introducing a new external caller with its exclusive secret key entails updating the app-config.yaml file and restarting the backend. This mechanism is designed to support integration with selected services, and therefore dynamic addition of callers is not necessary at this point.
Scopes are optional. If you do not specify any scopes, the access method in question has unlimited scope and can perform all types of action.
In the examples below, either a string or a list of strings can be given.
If any scope rule matches, the action is permitted. I.e., if you specify a plugin rule, then also adding permission rules for that plugin will have no effect since the plugin rule will have already matched.
You can limit by the target plugin ID being accessed with the token:
scope:
plugin: catalog
You can limit by the type of permission being requested:
scope:
permission: catalog.entity.read
Or by permissions attribute:
scope:
permissionAttributes: { action: read }
Configuration example:
backend:
auth:
externalAccess:
- type: legacy
options:
secret: ${EXTERNAL_ACCESS_SIGNATURE_SECRET}
# scope: ...
This corresponds exactly to the old shared secret signature method, and any secrets entered here will be merged with those specified under backend.auth.keys if any.
Configuration example:
backend:
auth:
externalAccess:
- type: static
options:
token: ${SERVICE_API_TOKEN}
# scope: ...
This allows you to specify any static token string as an API key, which callers send verbatim as the header Authorization: Bearer <token>.
The token verification of the two access methods will live entirely with the coreServices.auth service implementation, and will be returning regular credentials with a service principal. The implementation will only need to alter the authenticate method and no additions to the API will be necessary.
The service principal type will need to be amended with an optional scope field, carried over from the configuration. This will let the ServerPermissionClient compare against the set of allowlisted actions. In addition, the auth service can early-reject based on plugin ID if such rules are given.
The initial pilot implementation of the legacy and static types has already been merged, along with the corresponding config additions.
Validating this implementation, and adding scope restrictions, is still on todo but could be added incrementally at any time without breaking changes - as long as the default scope is "all".
We have left it out of the scope of this BEP to allow for addition of more access types. Making that a possibility will likely require the addition of service extension points at a framework level, which is not yet made a priority.
Potential future access types (not part of this BEP, but rather only for illustration) could for example be:
backend:
auth:
externalAccess:
- type: jwks
options:
url: https://other-service.acme.org/.well-known/jwks.json
- type: certificate
options:
publicCert:
$file: ./service-cert.pem
This approach involves storing shared secrets dynamically in a database. Adding or removing a shared secret would not require changes to the app-config.yaml, and managing secrets would not require restarting the application. This solution offers clear benefits, but may encounter objections due to its complexity and the potential lack of need for such flexibility.
Alternatively, we could maintain the current state by allowing callers to independently compose JWT tokens suitable for the existing implementation. However, this approach may prove challenging given the diverse types and environmental setups of external callers.
Another alternative is to retain access control on a per-plugin basis, as demonstrated in https://github.com/backstage/backstage/pull/23441. Given the repetitive nature of this use-case, establishing a common mechanism may present a more favorable option.
Implementing a shared token requester, as showcased in https://github.com/backstage/backstage/pull/23465, offers another potential solution. This approach may streamline token management and enhance accessibility, but requires thorough consideration of its implications and integration into the existing system.