New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement the access restrictions feature of external access service tokens #24702
Conversation
Changed Packages
|
1ac8dac
to
82b4ea8
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sweet 😎 🚀
packages/backend-app-api/config.d.ts
Outdated
* applies for the framework level; individual plugins may have | ||
* their own access control mechanisms on top of this. | ||
*/ | ||
scope?: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking restrictions
here might be a better name? "scope" (in OAuth) is generally used for additions and extending the scope of what a token is authorized for. This is kind of the opposite, where we restrict access instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Naming is hard! "restrictions" sounds a bit like the inverse of "scope" as in being used for subtractions from an existing set. Maybe "allow" or "rules"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh and an addon - maybe the wording "where we restrict access" isn't how I think of it at least, this field is how we GRANT access - but the caveat is, the default value is "all" so to speak. If the default value weren't "all", then "scope" might be less confusing right? As a matter of fact, maybe that's a discussion to have; what SHOULD the default policy be? Or do we need to talk about making the field required?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think allowing all as a default policy is pretty sensible tbh, but I agree it gets a bit tricky where we start by restricting but then expand. Sill feeling that it makes sense with the example from below with either restrictions
or accessRestrictions
:
externalAccess:
- type: static
options: { ... }
accessRestrictions:
- plugin: scaffolder
- plugin: catalog
permissionAttributes: { action: read }
packages/backend-app-api/config.d.ts
Outdated
* the permissions system at all, are not affected by this | ||
* setting. | ||
*/ | ||
permissions?: string[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to have the discussion: do you think it would make sense to configure permissions per plugin instead? One particularly good reason for that I think is that if you're not also restricting to certain plugins the token scope will still be very wide. If you just restrict to catalog.entity.read
or w/e it is, then you'd still be allowed to call any other endpoint on any plugin that doesn't have permission integrations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's just that it's so redundant. You have to make the mapping from plugin to permission (and can accidentally list permissions under a plugin where they don't belong). It felt like an unnecessary complication.
You're right though that there's a grey area where permissions aren't enabled at all. I wish that the permissions were gated upfront like the auth is today, such that we can make framework code that rejects requests if tje token has permissions restrictions but the plugin DID NOT have a permissions limitation.
But let's play with the idea of what the config would look like. Would we use plugins as the top level perhaps?
externalAccess:
- type: static
options: { ... }
plugins:
- scaffolder # just a string
- catalog:
permissions: [A, B] # similar to frontend but awkward indent
or
externalAccess:
- type: static
options: { ... }
allow:
- plugin: scaffolder # make plugin mandatory
- plugin: catalog
permissionAttributes: { action: read }
and then people just have to make the right mapping.
I think the last one kinda looks clear and concise enough. Maybe like you say, name it accessRestrictions
or restrictions
instead of allow
, that's another side discussion. Drawback is, you can't give a token blanket permission "you can perform reads anywhere", but as long as there aren't permission checks everywhere, i guess that won't be a possibility anyway
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep that last one is what I had in mind
return createCredentialsWithServicePrincipal( | ||
externalResult.subject, | ||
undefined, | ||
externalResult.scope, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The scope doesn't get forwarded to plugin request tokens. For example, if you've issued a token that allows a user to call the TechDocs APIs, you will indirectly get access to read from the catalog, or at least enumerate entities. I don't think that's necessarily wrong, but something to document clearly and think a bit more about.
One option could be to forward the external request token, but that kinda goes against what we're trying to do here. We could also forward the scope in the plugin request tokens. That'll keep things more locked down, but it does mean that you'll need to anticipate all of the indirect permissions that you need to allow for a token based on what calls the target plugin is making.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep I noted the same thing. I don't think we want to deal with transitive permissions though. It makes things much harder to reason about. We have to trust plugins to do the right thing anyway, with requests that come in. The same problem kind of applies to user tokens as well, right? There's a security perspective here for sure but I'd prefer to leave it out of scope here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep I think it's the way to go, just gotta make sure it's all clearly communicated too
0c7f2ff
to
fe1905a
Compare
b49481c
to
39842d5
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, digging into details a bit more
* individual plugins may have their own access control mechanisms | ||
* on top of this. | ||
*/ | ||
accessRestrictions?: Array<{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, wonder if we should avoid adding this to legacy auth? Feels a bit cleaner to add it only once we've introduced new methods for HMAC/asymmetric auth?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, we could. Internally it's basically all the same,but we can strip this out
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm thinking about this a bit more, why though? It's not like it's in the old auth.keys
section. I actually wanted the access restrictions to be outside of the type, in practice, such that you can put it on any external access method no matter what. I'd have wanted to declare this essentially as
externalAccess?: Array<
| ExternalAccessItem<'legacy', LegacyOptions>
| ExternalAccessItem<'static', StaticOptions>
>,
or the corresponding thing in json-schema, and it just being part of the actual item structure whether you want it or not. But the format isn't particularly amenable to it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only reason is really to drive adoption of new formats once available. No super strong opinion here though, if you think it's better for symmetry I think we can keep it too.
r => r.pluginId === this.pluginId, | ||
) | ||
) { | ||
throw new NotAllowedError('Access restricted'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a bit more info here tbh? Thinking about the case where someone provides a token to someone else and they then get this error when making a call. Something like "the provided token is restricted to the '...' plugin(s)". Feels like that handling of user input is relatively safe
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure! Maybe I was overly cautious here. After all, these are tokens that you may hand out to random systems on the internet so if they leak, this lets attackers iterate a bit more easily. But I can add that info
packages/backend-app-api/src/services/implementations/auth/external/helpers.ts
Show resolved
Hide resolved
@@ -136,6 +137,15 @@ export type BackstageNonePrincipal = { | |||
type: 'none'; | |||
}; | |||
|
|||
// @public | |||
export type BackstagePrincipalAccessRestrictions = Array<{ | |||
pluginId: string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, not sure this should be here. At this point we've already validated the plugin right? I think it would even make sense to unpack the permission restrictions to apply to this plugin, i.e. this shouldn't be an array
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm interesting. Yeah maybe that will always be the case in practice. It sure should be true today. Good observation, I could scale this back then
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Especially if we aren't going to send this transitively to upstreams
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, and if we find that to be a huge misstep we should be able to add it back in as restrictions for other plugins in a separate field, or something of that sort.
39842d5
to
060f7ea
Compare
}) { | ||
this.#auth = options.auth; | ||
this.#permissionClient = options.permissionClient; | ||
this.#permissionEnabled = options.permissionEnabled; | ||
this.#pluginId = options.pluginId; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't needed anymore right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct! I thought I addressed that. Fixed.
} | ||
|
||
async authorizeConditional( | ||
queries: QueryPermissionRequest[], | ||
options?: PermissionsServiceRequestOptions, | ||
): Promise<PolicyDecision[]> { | ||
const maybeResponse = this.#decideBasedOnPrincipalAccessRestrictions( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feeling it might be a bit cleaner to move this down to replace the queries.map(_ => ({ result: AuthorizeResult.ALLOW }))
logic? Having that be the service principal path where we decide based on the access restrictions. Feels a bit strange that we're checking for the service principal twice right now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason I made it a standalone method that's done at the top, is that I wanted to retain the old fallback functionality perfectly (which ALSO in a way has logic for this token type). Thus, when no restrictions exist, it'll just keep running the old code path. Would you prefer I update that? Just kinda have to unwind shouldPermissionsBeApplied
a little then.
Hm, or maybe not. I think I see what you mean. I'll move over that code tomorrow and see how it feels.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok @Rugvip tried for a bit of a refactor - but the way that things are intertwined, am not sure it got all that much better 🤔
verifyToken(token: string): Promise< | ||
| { | ||
subject: string; | ||
accessRestrictions?: AccessRestriptionsMap; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we also already know the plugin ID right? as in this could be a BackstagePrincipalAccessRestrictions
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually no. The ExternalTokenHandler is made in createRootContext
, so it's the DefaultAuthService
that's responsible for picking out the right entry in the map for now. We can refactor that later though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking it would be a bit cleaner to have the token handlers be per plugin though, it's what I was on about over here: #24321 (review)
1827970
to
6587e84
Compare
c8b85dc
to
547a529
Compare
… BEP-0007 Signed-off-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
547a529
to
0639b07
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Najs, 🚢 🇮🇹
Thank you for contributing to Backstage! The changes in this pull request will be part of the |
Uffizzi Cluster |
As part of BEP-0007.
The config format is a little different from the internal names of things, for convenience. Open to suggestions here.
I put it as a visible type on the service principal, even though I really originally intended for it to be hidden on the internal credential type instead. This would let us iterate more easily on it in secrecy. But reaching that field becomes cumbersome since the types involved are only in backend-app-api, but need to be consumed from plugin-permission-node. In the end, I decided to hope that these interfaces are stable enough, that there isn't significant risk in setting this in stone in backend-plugin-api. Again, feedback is welcome.
Another potential middle ground could be to make the scope type something semi opaque like
Record<string, string[]>
and have different subsystems use keys in that as they see fit.Regard this as being up for early review, as I'm away for a little bit now.