Skip to content
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

New feature: Allow authentication by personal tokens #53

Merged
merged 11 commits into from
Nov 9, 2023
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,28 @@ Say goodbye to your Git dependencies, `gitlab-cargo-shim` is a stateless SSH ser

Access controls work like they do in GitLab, builds are scoped to users - if they don't have permission to the dependency they can't build it, it's that simple.

Users are identified by their SSH keys from GitLab when connecting to the server and an [impersonation token][imp-token] will be generated for that run in order to pull available versions. Builds will insert their token as a username to the SSH server and the shim will use that to call the GitLab API.
Users are either identified by their SSH keys from GitLab when connecting to the server or by an Gitlab personal-token. If no token is given, an [impersonation token][imp-token] will be generated for that run in order to pull available versions. Doing so requires ad admin personal token.

To publish run `cargo package` and push the resulting `.crate` file to the GitLab package repository with a semver-compatible version string, to consume the package configure your `.cargo/config.toml` and `Cargo.toml` accordingly.
To publish run `cargo package` and push the resulting `.crate` file to the GitLab package repository with a semver-compatible version string, to consume the package configure your `.cargo/config.toml`, `Cargo.toml` and, optionally, `.ssh/config` accordingly.

At time of writing, `libssh2`, which `cargo` implicitly uses for communicating with the registry by SSH, is incompatible with rust's `thrussh`, due to non-overlapping ciphers. Hence, activating `net.git-fetch-with-cli` is necessary.

```toml
# .cargo/config.toml
[registries]
my-gitlab-project = { index = "ssh://gitlab-cargo-shim.local/my-gitlab-group/my-gitlab-project" }
my-gitlab-project = { index = "ssh://gitlab-cargo-shim.local/my-gitlab-group/my-gitlab-project/" }
[net]
git-fetch-with-cli = true

# Cargo.toml
[dependencies]
my-crate = { version = "0.1", registry = "my-gitlab-project" }
```
```ssh-config
# .ssh/config (only if authentication by personal token is requires)
Host gitlab-cargo-shim.local
User personal-token:<your-personal-token>
```

In your CI build, setup a `before_script` step to replace the connection string with one containing the CI token:
Copy link
Owner

Choose a reason for hiding this comment

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

Could you leave these lines in please? A CI token would still be required to build

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok. I removed them because embedding the CI token into the registry's URL does not work anymore with the newest cargo version. (Please correct me if I oversee something.)

I changed the commit such that these lines remain and a note regarding newer versions of cargo is added.

Copy link
Owner

Choose a reason for hiding this comment

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

That is true, these instructions were wrong as established in #28 but it was never corrected


Expand All @@ -34,13 +43,13 @@ To release your package from CI, add a new pipeline step:

```yaml
release-crate:
image: rust:1.62
image: rust:latest
stage: deploy
only: # release when a tag is pushed
- tags
before_script:
- cargo install cargo-get
- export CRATE_NAME=$(cargo get --name) CRATE_VERSION=$(cargo get version)
- export CRATE_NAME=$(cargo-get package.name) CRATE_VERSION=$(cargo-get package.version)
- export CRATE_FILE=${CRATE_NAME}-${CRATE_VERSION}.crate
script:
- cargo package
Expand All @@ -54,4 +63,4 @@ It's that easy. Go forth and enjoy your newfound quality of life improvements, R
[gitlab-package-registry]: https://docs.gitlab.com/ee/user/packages/package_registry/index.html
[imp-token]: https://docs.gitlab.com/ee/api/index.html#impersonation-tokens
[envvar]: https://doc.rust-lang.org/cargo/reference/registries.html#using-an-alternate-registry
[example-configuration]: https://github.com/w4/gitlab-cargo-shim/blob/main/config.toml
[example-configuration]: https://github.com/w4/gitlab-cargo-shim/blob/main/config.toml
9 changes: 6 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ impl<U: UserProvider + PackageProvider + Send + Sync + 'static> Handler<U> {

// fetch metadata from the provider
let metadata = Arc::clone(&self.gitlab)
.fetch_metadata_for_release(path, crate_version)
.fetch_metadata_for_release(path, crate_version, self.user()?)
.await?;

// transform the `cargo metadata` output to the cargo index
Expand Down Expand Up @@ -285,7 +285,10 @@ impl<U: UserProvider + PackageProvider + Send + Sync + 'static> Handler<U> {

// fetch the impersonation token for the user we'll embed
// the `dl` string.
let token = self.gitlab.fetch_token_for_user(self.user()?).await?;
let token = match &self.user()?.token {
None => self.gitlab.fetch_token_for_user(self.user()?).await?,
Some(token) => token.clone(),
};
Copy link
Owner

Choose a reason for hiding this comment

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

Clippy will probably complain about this since it can be if let Some(..) = ... else ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the review! It seems like clippy didn't have a problem with this particular line but, however, with two other lines in my code. I fixed them accordingly.


// generate the config for the user, containing the download
// url template from gitlab and the impersonation token embedded
Expand Down Expand Up @@ -402,7 +405,7 @@ impl<U: UserProvider + PackageProvider + Send + Sync + 'static> thrussh::server:
info!(
"Successfully authenticated for GitLab user `{}` by {}",
&user.username,
if by_ssh_key { "SSH Key" } else { "Build Token" },
if by_ssh_key { "SSH Key" } else { "Build or Personal Token" },
);
self.user = Some(Arc::new(user));
self.finished_auth(Auth::Accept).await
Expand Down
98 changes: 73 additions & 25 deletions src/providers/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::{borrow::Cow, sync::Arc};
use time::{Duration, OffsetDateTime};
use tracing::{info_span, instrument, Instrument};
use url::Url;
use std::str::FromStr;

pub struct Gitlab {
client: reqwest::Client,
Expand Down Expand Up @@ -46,6 +47,19 @@ impl Gitlab {
ssl_cert,
})
}

pub fn build_client_with_token(&self, token_field: &str, token: &str) -> anyhow::Result<reqwest::Client> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::HeaderName::from_str(token_field)?,
header::HeaderValue::from_str(token)?,
);
let mut client_builder = reqwest::ClientBuilder::new().default_headers(headers);
if let Some(cert) = &self.ssl_cert {
client_builder = client_builder.add_root_certificate(cert.clone());
}
Ok(client_builder.build()?)
}
}

#[async_trait]
Expand All @@ -60,29 +74,43 @@ impl super::UserProvider for Gitlab {
return Ok(None);
};

if username == "gitlab-ci-token" {
if username == "gitlab-ci-token" || username == "personal-token" {
// we're purposely not using `self.client` here as we don't
// want to use our admin token for this request but still want to use any ssl cert provided.
let mut client_builder = reqwest::Client::builder();
if let Some(cert) = &self.ssl_cert {
client_builder = client_builder.add_root_certificate(cert.clone());
let client = self.build_client_with_token(if username == "gitlab-ci-token" { "JOB-TOKEN" } else { "PRIVATE-TOKEN" }, password);
if username == "gitlab-ci-token" {
let res: GitlabJobResponse = handle_error(
client?
.get(self.base_url.join("job/")?)
.send()
.await?,
)
.await?
.json()
.await?;

Ok(Some(User {
id: res.user.id,
username: res.user.username,
..Default::default()
}))
} else {
let res: GitlabUserResponse = handle_error(
client?
.get(self.base_url.join("user/")?)
.send()
.await?,
)
.await?
.json()
.await?;

Ok(Some(User {
id: res.id,
username: res.username,
token: Some(password.to_string()),
}))
}
let client = client_builder.build();
let res: GitlabJobResponse = handle_error(
client?
.get(self.base_url.join("job/")?)
.header("JOB-TOKEN", password)
.send()
.await?,
)
.await?
.json()
.await?;

Ok(Some(User {
id: res.user.id,
username: res.user.username,
}))
} else {
Ok(None)
}
Expand All @@ -101,6 +129,7 @@ impl super::UserProvider for Gitlab {
Ok(res.user.map(|u| User {
id: u.id,
username: u.username,
..Default::default()
}))
}

Expand Down Expand Up @@ -149,15 +178,23 @@ impl super::PackageProvider for Gitlab {
query.append_pair("per_page", itoa::Buffer::new().format(100u16));
query.append_pair("pagination", "keyset");
query.append_pair("sort", "asc");
query.append_pair("sudo", itoa::Buffer::new().format(do_as.id));
if do_as.token.is_none() {
query.append_pair("sudo", itoa::Buffer::new().format(do_as.id));
}
}
uri
});

let futures = FuturesUnordered::new();

let client = match &do_as.token {
None => self.client.clone(),
Some(token) => self.build_client_with_token("PRIVATE-TOKEN", token)?
};
let client = Arc::new(client);

while let Some(uri) = next_uri.take() {
let res = handle_error(self.client.get(uri).send().await?).await?;
let res = handle_error(client.get(uri).send().await?).await?;

if let Some(link_header) = res.headers().get(header::LINK) {
let mut link_header = parse_link_header::parse_with_rel(link_header.to_str()?)?;
Expand All @@ -167,10 +204,15 @@ impl super::PackageProvider for Gitlab {
}
}

let res: Vec<GitlabPackageResponse> = res.json().await?;
let res: Vec<GitlabPackageResponse> = res.json::<Vec<GitlabPackageResponse>>()
.await?
.into_iter()
.filter(|release| release.package_type == "generic")
.collect();

for release in res {
let this = Arc::clone(&self);
let client = Arc::clone(&client);

futures.push(tokio::spawn(
async move {
Expand All @@ -189,7 +231,7 @@ impl super::PackageProvider for Gitlab {
});

let package_files: Vec<GitlabPackageFilesResponse> = handle_error(
this.client
client
.get(format!(
"{}/projects/{}/packages/{}/package_files",
this.base_url,
Expand Down Expand Up @@ -239,10 +281,15 @@ impl super::PackageProvider for Gitlab {
&self,
path: &Self::CratePath,
version: &str,
do_as: &User,
) -> anyhow::Result<cargo_metadata::Metadata> {
let uri = self.base_url.join(&path.metadata_uri(version))?;
let client = match &do_as.token {
None => self.client.clone(),
Some(token) => self.build_client_with_token("PRIVATE-TOKEN", token)?
};

Ok(handle_error(self.client.get(uri).send().await?)
Ok(handle_error(client.get(uri).send().await?)
.await?
.json()
.await?)
Expand Down Expand Up @@ -315,6 +362,7 @@ pub struct GitlabPackageResponse {
pub id: u64,
pub name: String,
pub version: String,
pub package_type: String,
#[serde(rename = "_links")]
pub links: GitlabPackageLinksResponse,
}
Expand Down
4 changes: 3 additions & 1 deletion src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ pub trait PackageProvider {
&self,
path: &Self::CratePath,
version: &str,
do_as: &User,
) -> anyhow::Result<cargo_metadata::Metadata>;

fn cargo_dl_uri(&self, project: &str, token: &str) -> anyhow::Result<String>;
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct User {
pub id: u64,
pub username: String,
pub token: Option<String>,
}

pub type ReleaseName = Arc<str>;
Expand Down