Skip to content

Conversation

jmpesp
Copy link
Contributor

@jmpesp jmpesp commented Oct 9, 2025

Implement the CRUD routines for the tokens that will be used to authenticate SCIM clients for SamlScim Silos. Also implement the Bearer based authentication for SCIM clients.

Fill in the skeleton of CrdbScimProviderStore, which when implemented will complete the SCIM implementation in Nexus.

Implement the CRUD routines for the tokens that will be used to
authenticate SCIM clients for SamlScim Silos. Also implement the Bearer
based authentication for SCIM clients.

Fill in the skeleton of CrdbScimProviderStore, which when implemented
will complete the SCIM implementation in Nexus.
@jmpesp jmpesp requested a review from david-crespo October 9, 2025 02:56
Instead of silo

Also grant silo admin role to USER_TEST_PRIVILEGED.id() instead of
OpContext::for_tests in tests.
"create_child" if "external-authenticator" on "parent_fleet";

# external scim has to be able to read SCIM tokens
"list_children" if "external-scim" on "parent_fleet";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

Copy link
Contributor

@david-crespo david-crespo Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I can't find a spot where the external-scim opctx needs to do this. Is it intended for the unimplemented list users operation etc?


/// Authenticate a SCIM client based on a bearer token, and return a SCIM
/// provider store implementation that is scoped to a Silo.
pub(crate) async fn scim_idp_get_provider(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good for this name to make clearer that it handles token authentication. scim_idp_get_provider sounds like a lookup. It might even make sense to split it into two pieces, one that is the authn and one that gets the provider based on the token. But that's silly if they're always called together, and you don't really want to return the token back to the call site either. scim_token_auth_and_get_provider is a bit much I guess.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be more relevant in the next PR, when you implement the actual SCIM operations, but it could make sense to mirror op_context_for_external_api and wrap up the authentication and the creation of the op context into one operation. Right now it looks like the unimplemented SCIM things don't take and OpContext but they will need to in order to do DB operations. Or at least, that's how I think it should work: op context created at top level at authn time and then passed into the provider methods.

/// Authenticates an incoming request to the external API and produces a new
/// operation context for it
pub(crate) async fn op_context_for_external_api(
rqctx: &dropshot::RequestContext<ApiContext>,
) -> Result<OpContext, dropshot::HttpError> {
let apictx = rqctx.context();
OpContext::new_async(
&rqctx.log,
async {
let authn = Arc::new(
apictx.context.external_authn.authn_request(rqctx).await?,
);
let datastore = Arc::clone(apictx.context.nexus.datastore());
let authz = authz::Context::new(
Arc::clone(&authn),
Arc::clone(&apictx.context.authz),
datastore,
);
Ok((authn, authz))
},
|metadata| OpContext::load_request_metadata(rqctx, metadata),
OpKind::ExternalApiRequest,
)
.await
}

@david-crespo
Copy link
Contributor

I had a good conversation with the Codex CLI about the choice to effectively bypass the existing authn schemes setup here, treating these calls as "unauthed" from the point of view of the main authn system. The robot was pretty persuasive but I think the choice is worth pointing out and documenting in comments, probably on the scim_idp_get_provider.

https://gist.github.com/david-crespo/14d25cb770f9e10195f6d0ea45286874

opctx: &OpContext,
authz_silo: &authz::Silo,
) -> ListResultVec<ScimClientBearerToken> {
opctx.authorize(authz::Action::ListChildren, authz_silo).await?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to me too lenient of a check. Any silo viewer has ListChildren on the silo. Maybe that's fine considering the list of tokens doesn't actually tell you anything. But it's something to think about.

resource: Silo "silo1"
USER Q R LC RP M MP CC D
fleet-admin ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔
fleet-collaborator ✘ ✔ ✘ ✔ ✔ ✔ ✘ ✔
fleet-viewer ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘
silo1-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔
silo1-collaborator ✘ ✔ ✔ ✔ ✘ ✘ ✔ ✘
silo1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘
silo1-proj1-admin ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘
silo1-proj1-collaborator ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘
silo1-proj1-viewer ✘ ✔ ✘ ✔ ✘ ✘ ✘ ✘
unauthenticated ! ! ! ! ! ! ! !

The only way I know of to make this more restrictive is to create a "synthetic" authz resource representing the list of scim tokens for a given silo and then do the authz checks against that. Here's an example of a synthetic authz list resource with its own permissions distinct from silo ListChildren:

/// Synthetic resource describing the list of Identity Providers associated with
/// a Silo
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SiloIdentityProviderList(Silo);
impl SiloIdentityProviderList {
pub fn new(silo: Silo) -> SiloIdentityProviderList {
SiloIdentityProviderList(silo)
}
pub fn silo(&self) -> &Silo {
&self.0
}
}
impl oso::PolarClass for SiloIdentityProviderList {
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
oso::Class::builder()
.with_equality_check()
.add_attribute_getter("silo", |list: &SiloIdentityProviderList| {
list.0.clone()
})
}
}

) -> ListResultVec<views::ScimClientBearerToken> {
let (.., _authz_silo, _) =
let (.., authz_silo, _) =
silo_lookup.fetch_for(authz::Action::ListChildren).await?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need to specify ListChildren here, you can just do .fetch() and let the datastore method handle the permission relevant on the token list.

id: Uuid::new_v4(),
time_created: Utc::now(),
time_deleted: None,
// TODO: allow setting an expiry? have a silo default?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind filing a follow up ticket for this and noting it in the comment

use nexus_db_schema::schema::scim_client_bearer_token::dsl;
let maybe_token = dsl::scim_client_bearer_token
.filter(dsl::bearer_token.eq(bearer_token))
.filter(dsl::time_deleted.is_null())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need filters for where we are not past the expired time here and other places?

Comment on lines +100 to +107
let token = match header.to_str() {
Ok(v) => v,
Err(_) => {
return Err(Error::Unauthenticated {
internal_message: "Invalid bearer token".to_string(),
});
}
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let token = match header.to_str() {
Ok(v) => v,
Err(_) => {
return Err(Error::Unauthenticated {
internal_message: "Invalid bearer token".to_string(),
});
}
};
let Ok(token) = header.to_str() else {
return Err(Error::Unauthenticated {
internal_message: "Invalid bearer token".to_string(),
});
};


const BEARER: &str = "Bearer ";

if !token.starts_with(BEARER) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember where we landed on this but assuming this logic is correct we are going to require operators to use Bearer xxx-xxx-xxx-xxx on the IdP side rather than just a raw header value?

Also the logic could simply become:

        let Some(token) = token.strip_prefix(BEARER) else {
            return Err(Error::Unauthenticated {
                internal_message: "Invalid bearer token".to_string(),
            });
        };

Then that could be passed to scim_idp_lookup_token_by_bearer() directly.


assert!(tokens.is_empty());

// Fleet admins can create SCIM client tokens
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just going off of the comment here, but can we add a test that a non fleet admin cannot create a SCIM client token?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants