mirror of
https://github.com/github/codeql-action.git
synced 2026-05-08 14:50:21 +00:00
Merge pull request #3850 from github/mbg/private-registry/cloudsmith-gcp
Private registries: Add support for Cloudsmith and GCP OIDC configurations
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import test from "ava";
|
||||
|
||||
import { makeFromSchema, withSchemaMatrix } from "../json/testing-util";
|
||||
import { setupTests } from "../testing-utils";
|
||||
|
||||
import * as types from "./types";
|
||||
@@ -26,6 +27,38 @@ const validJFrogCredential: types.JFrogConfig = {
|
||||
"identity-mapping-name": "my-mapping",
|
||||
};
|
||||
|
||||
test("hasUsername", (t) => {
|
||||
// Reject the case where `username` is missing.
|
||||
t.false(types.hasUsername({}));
|
||||
|
||||
// Test all cases where `username` is present.
|
||||
withSchemaMatrix(
|
||||
t,
|
||||
types.usernameSchema,
|
||||
{ excludeAbsent: true },
|
||||
(value) => {
|
||||
t.true(types.hasUsername(value));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("hasUsernameAndPassword", (t) => {
|
||||
// Reject cases where `username` or `password` are missing.
|
||||
t.false(types.hasUsernameAndPassword({}));
|
||||
t.false(types.hasUsernameAndPassword({ username: "foo" }));
|
||||
t.false(types.hasUsernameAndPassword({ password: "foo" }));
|
||||
|
||||
// Test all cases where both `username` and `password` are present.
|
||||
withSchemaMatrix(
|
||||
t,
|
||||
types.usernamePasswordSchema,
|
||||
{ excludeAbsent: true },
|
||||
(value) => {
|
||||
t.true(types.hasUsernameAndPassword(value));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("credentialToStr - pretty-prints valid username+password configurations", (t) => {
|
||||
const secret = "password123";
|
||||
const credential: types.Credential = {
|
||||
@@ -107,13 +140,46 @@ test("credentialToStr - pretty-prints valid JFrog OIDC configurations", (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("credentialToStr - pretty-prints valid Cloudsmith OIDC configurations", (t) => {
|
||||
const credential: types.Credential = {
|
||||
type: "maven_credential",
|
||||
url: "https://localhost",
|
||||
...(makeFromSchema(
|
||||
true,
|
||||
types.cloudsmithConfigSchema,
|
||||
) as types.CloudsmithConfig),
|
||||
};
|
||||
|
||||
const str = types.credentialToStr(credential);
|
||||
|
||||
t.is(
|
||||
"Type: maven_credential; Url: https://localhost; Cloudsmith Namespace: value-for-namespace; Cloudsmith Service Slug: value-for-service-slug; Cloudsmith API Host: value-for-api-host;",
|
||||
str,
|
||||
);
|
||||
});
|
||||
|
||||
test("credentialToStr - pretty-prints valid GCP OIDC configurations", (t) => {
|
||||
const credential: types.Credential = {
|
||||
type: "maven_credential",
|
||||
url: "https://localhost",
|
||||
...(makeFromSchema(true, types.gcpConfigSchema) as types.GCPConfig),
|
||||
};
|
||||
|
||||
const str = types.credentialToStr(credential);
|
||||
|
||||
t.is(
|
||||
"Type: maven_credential; Url: https://localhost; GCP Workload Identity Provider: value-for-workload-identity-provider; GCP Service Account: value-for-service-account; GCP Audience: value-for-audience;",
|
||||
str,
|
||||
);
|
||||
});
|
||||
|
||||
test("credentialToStr - hides passwords", (t) => {
|
||||
const secret = "password123";
|
||||
const credential = {
|
||||
type: "maven_credential",
|
||||
password: secret,
|
||||
url: "https://localhost",
|
||||
};
|
||||
} satisfies types.Credential;
|
||||
|
||||
const str = types.credentialToStr(credential);
|
||||
|
||||
@@ -127,7 +193,7 @@ test("credentialToStr - hides tokens", (t) => {
|
||||
type: "maven_credential",
|
||||
token: secret,
|
||||
url: "https://localhost",
|
||||
};
|
||||
} satisfies types.Credential;
|
||||
|
||||
const str = types.credentialToStr(credential);
|
||||
|
||||
|
||||
+134
-88
@@ -9,144 +9,177 @@ import { isDefined } from "../util";
|
||||
*/
|
||||
export type RawCredential = UnvalidatedObject<Credential>;
|
||||
|
||||
/** Usernames may be present for both authentication with tokens or passwords. */
|
||||
export type Username = {
|
||||
/** A schema for credential objects with a username. */
|
||||
export const usernameSchema = {
|
||||
/** The username needed to authenticate to the package registry, if any. */
|
||||
username?: string;
|
||||
};
|
||||
username: json.optional(json.string),
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/** Decides whether `config` has a username. */
|
||||
/** Usernames may be present for both authentication with tokens or passwords. */
|
||||
export type Username = json.FromSchema<typeof usernameSchema>;
|
||||
|
||||
/**
|
||||
* Narrows `config` to `Username` if `config` has a `username` property.
|
||||
* Not used for validation. Assumes that `config` is already a validated `AuthConfig`.
|
||||
*/
|
||||
export function hasUsername(config: AuthConfig): config is Username {
|
||||
return "username" in config;
|
||||
}
|
||||
|
||||
/** A schema for credential objects with a username and password. */
|
||||
export const usernamePasswordSchema = {
|
||||
/** The password needed to authenticate to the package registry, if any. */
|
||||
password: json.optional(json.string),
|
||||
...usernameSchema,
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/**
|
||||
* Fields expected for authentication based on a username and password.
|
||||
* Both username and password are optional.
|
||||
*/
|
||||
export type UsernamePassword = {
|
||||
/** The password needed to authenticate to the package registry, if any. */
|
||||
password?: string;
|
||||
} & Username;
|
||||
export type UsernamePassword = json.FromSchema<typeof usernamePasswordSchema>;
|
||||
|
||||
/** Decides whether `config` is based on a username and password. */
|
||||
export function isUsernamePassword(
|
||||
/**
|
||||
* Narrows `config` to `UsernamePassword` if it has a `username` and `password` property.
|
||||
* Not used for validation. Assumes that `config` is already a validated `AuthConfig`.
|
||||
*/
|
||||
export function hasUsernameAndPassword(
|
||||
config: AuthConfig,
|
||||
): config is UsernamePassword {
|
||||
return hasUsername(config) && "password" in config;
|
||||
}
|
||||
|
||||
/** A schema for credential objects for token-based authentication. */
|
||||
export const tokenSchema = {
|
||||
/** The token needed to authenticate to the package registry, if any. */
|
||||
token: json.optional(json.string),
|
||||
...usernameSchema,
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/**
|
||||
* Fields expected for token-based authentication.
|
||||
* Both username and token are optional.
|
||||
*/
|
||||
export type Token = {
|
||||
/** The token needed to authenticate to the package registry, if any. */
|
||||
token?: string;
|
||||
} & Username;
|
||||
export type Token = json.FromSchema<typeof tokenSchema>;
|
||||
|
||||
/**
|
||||
* Narrows `config` to `Token` if it has a `token` property.
|
||||
* Not used for validation. Assumes that `config` is already a validated `AuthConfig`.
|
||||
*/
|
||||
export function hasToken(config: AuthConfig): config is Token {
|
||||
return "token" in config;
|
||||
}
|
||||
|
||||
/** Decides whether `config` is token-based. */
|
||||
export function isToken(
|
||||
config: UnvalidatedObject<AuthConfig>,
|
||||
): config is Token {
|
||||
// The "username" field is optional, but should be a string if present.
|
||||
if ("username" in config && !json.isStringOrUndefined(config.username)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The "token" field is required, and must be a string or undefined.
|
||||
return "token" in config && json.isStringOrUndefined(config.token);
|
||||
return "token" in config && json.validateSchema(tokenSchema, config);
|
||||
}
|
||||
|
||||
/** A schema for Azure OIDC configurations. */
|
||||
export const azureConfigSchema = {
|
||||
"tenant-id": json.string,
|
||||
"client-id": json.string,
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/** Configuration for Azure OIDC. */
|
||||
export type AzureConfig = { "tenant-id": string; "client-id": string };
|
||||
export type AzureConfig = json.FromSchema<typeof azureConfigSchema>;
|
||||
|
||||
/** Decides whether `config` is an Azure OIDC configuration. */
|
||||
export function isAzureConfig(
|
||||
config: UnvalidatedObject<AuthConfig>,
|
||||
): config is AzureConfig {
|
||||
return (
|
||||
"tenant-id" in config &&
|
||||
"client-id" in config &&
|
||||
isDefined(config["tenant-id"]) &&
|
||||
isDefined(config["client-id"]) &&
|
||||
json.isString(config["tenant-id"]) &&
|
||||
json.isString(config["client-id"])
|
||||
);
|
||||
return json.validateSchema(azureConfigSchema, config);
|
||||
}
|
||||
|
||||
/** A schema for AWS OIDC configurations. */
|
||||
export const awsConfigSchema = {
|
||||
"aws-region": json.string,
|
||||
"account-id": json.string,
|
||||
"role-name": json.string,
|
||||
domain: json.string,
|
||||
"domain-owner": json.string,
|
||||
audience: json.optional(json.string),
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/** Configuration for AWS OIDC. */
|
||||
export type AWSConfig = {
|
||||
"aws-region": string;
|
||||
"account-id": string;
|
||||
"role-name": string;
|
||||
domain: string;
|
||||
"domain-owner": string;
|
||||
audience?: string;
|
||||
};
|
||||
export type AWSConfig = json.FromSchema<typeof awsConfigSchema>;
|
||||
|
||||
/** Decides whether `config` is an AWS OIDC configuration. */
|
||||
export function isAWSConfig(
|
||||
config: UnvalidatedObject<AuthConfig>,
|
||||
): config is AWSConfig {
|
||||
// All of these properties are required.
|
||||
const requiredProperties = [
|
||||
"aws-region",
|
||||
"account-id",
|
||||
"role-name",
|
||||
"domain",
|
||||
"domain-owner",
|
||||
];
|
||||
|
||||
for (const property of requiredProperties) {
|
||||
if (
|
||||
!(property in config) ||
|
||||
!isDefined(config[property]) ||
|
||||
!json.isString(config[property])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The "audience" field is optional, but should be a string if present.
|
||||
if ("audience" in config && !json.isStringOrUndefined(config.audience)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return json.validateSchema(awsConfigSchema, config);
|
||||
}
|
||||
|
||||
/** A schema for JFrog OIDC configurations. */
|
||||
export const jfrogConfigSchema = {
|
||||
"jfrog-oidc-provider-name": json.string,
|
||||
audience: json.optional(json.string),
|
||||
"identity-mapping-name": json.optional(json.string),
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/** Configuration for JFrog OIDC. */
|
||||
export type JFrogConfig = {
|
||||
"jfrog-oidc-provider-name": string;
|
||||
audience?: string;
|
||||
"identity-mapping-name"?: string;
|
||||
};
|
||||
export type JFrogConfig = json.FromSchema<typeof jfrogConfigSchema>;
|
||||
|
||||
/** Decides whether `config` is a JFrog OIDC configuration. */
|
||||
export function isJFrogConfig(
|
||||
config: UnvalidatedObject<AuthConfig>,
|
||||
): config is JFrogConfig {
|
||||
// The "audience" and "identity-mapping-name" fields are optional, but should be strings if present.
|
||||
if ("audience" in config && !json.isStringOrUndefined(config.audience)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
"identity-mapping-name" in config &&
|
||||
!json.isStringOrUndefined(config["identity-mapping-name"])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
"jfrog-oidc-provider-name" in config &&
|
||||
isDefined(config["jfrog-oidc-provider-name"]) &&
|
||||
json.isString(config["jfrog-oidc-provider-name"])
|
||||
);
|
||||
return json.validateSchema(jfrogConfigSchema, config);
|
||||
}
|
||||
|
||||
/** A schema for Cloudsmith OIDC configurations. */
|
||||
export const cloudsmithConfigSchema = {
|
||||
namespace: json.string,
|
||||
"service-slug": json.string,
|
||||
"api-host": json.string,
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/** Configuration for Cloudsmith OIDC. */
|
||||
export type CloudsmithConfig = json.FromSchema<typeof cloudsmithConfigSchema>;
|
||||
|
||||
/** Decides whether `config` is a Cloudsmith OIDC configuration. */
|
||||
export function isCloudsmithConfig(
|
||||
config: UnvalidatedObject<AuthConfig>,
|
||||
): config is CloudsmithConfig {
|
||||
return json.validateSchema(cloudsmithConfigSchema, config);
|
||||
}
|
||||
|
||||
/** A schema for GCP OIDC configurations. */
|
||||
export const gcpConfigSchema = {
|
||||
"workload-identity-provider": json.string,
|
||||
"service-account": json.optional(json.string),
|
||||
audience: json.optional(json.string),
|
||||
} as const satisfies json.Schema;
|
||||
|
||||
/** Configuration for GCP OIDC. */
|
||||
export type GCPConfig = json.FromSchema<typeof gcpConfigSchema>;
|
||||
|
||||
/** Decides whether `config` is a GCP OIDC configuration. */
|
||||
export function isGCPConfig(
|
||||
config: UnvalidatedObject<AuthConfig>,
|
||||
): config is GCPConfig {
|
||||
return json.validateSchema(gcpConfigSchema, config);
|
||||
}
|
||||
|
||||
/** An array of all OIDC configuration schemas along with output-friendly names. */
|
||||
export const oidcSchemas = [
|
||||
{ schema: azureConfigSchema, name: "Azure" },
|
||||
{ schema: awsConfigSchema, name: "AWS" },
|
||||
{ schema: jfrogConfigSchema, name: "JFrog" },
|
||||
{ schema: cloudsmithConfigSchema, name: "Cloudsmith" },
|
||||
{ schema: gcpConfigSchema, name: "GCP" },
|
||||
];
|
||||
|
||||
/** Represents all supported OIDC configurations. */
|
||||
export type OIDC = AzureConfig | AWSConfig | JFrogConfig;
|
||||
export type OIDC =
|
||||
| AzureConfig
|
||||
| AWSConfig
|
||||
| JFrogConfig
|
||||
| CloudsmithConfig
|
||||
| GCPConfig;
|
||||
|
||||
/** All authentication-related fields. */
|
||||
export type AuthConfig = UsernamePassword | Token | OIDC;
|
||||
@@ -165,7 +198,7 @@ export type Credential = AuthConfig & Registry;
|
||||
export function credentialToStr(credential: Credential): string {
|
||||
let result: string = `Type: ${credential.type};`;
|
||||
|
||||
const appendIfDefined = (name: string, val: string | undefined) => {
|
||||
const appendIfDefined = (name: string, val: string | undefined | null) => {
|
||||
if (isDefined(val)) {
|
||||
result += ` ${name}: ${val};`;
|
||||
}
|
||||
@@ -184,7 +217,7 @@ export function credentialToStr(credential: Credential): string {
|
||||
isDefined(credential.password) ? "***" : undefined,
|
||||
);
|
||||
}
|
||||
if (isToken(credential)) {
|
||||
if (hasToken(credential)) {
|
||||
appendIfDefined("Token", isDefined(credential.token) ? "***" : undefined);
|
||||
}
|
||||
|
||||
@@ -205,6 +238,17 @@ export function credentialToStr(credential: Credential): string {
|
||||
credential["identity-mapping-name"],
|
||||
);
|
||||
appendIfDefined("JFrog Audience", credential.audience);
|
||||
} else if (isCloudsmithConfig(credential)) {
|
||||
appendIfDefined("Cloudsmith Namespace", credential.namespace);
|
||||
appendIfDefined("Cloudsmith Service Slug", credential["service-slug"]);
|
||||
appendIfDefined("Cloudsmith API Host", credential["api-host"]);
|
||||
} else if (isGCPConfig(credential)) {
|
||||
appendIfDefined(
|
||||
"GCP Workload Identity Provider",
|
||||
credential["workload-identity-provider"],
|
||||
);
|
||||
appendIfDefined("GCP Service Account", credential["service-account"]);
|
||||
appendIfDefined("GCP Audience", credential.audience);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -214,6 +258,8 @@ export function credentialToStr(credential: Credential): string {
|
||||
export type Registry = {
|
||||
/** The type of the package registry. */
|
||||
type: string;
|
||||
/** Whether the registry replaces the base registry for the ecosystem. */
|
||||
"replaces-base"?: boolean;
|
||||
} & Address;
|
||||
|
||||
// If a registry has an `url`, then that takes precedence over the `host` which may or may
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from "ava";
|
||||
|
||||
import * as json from "../json";
|
||||
import { makeFromSchema } from "../json/testing-util";
|
||||
import { setupTests } from "../testing-utils";
|
||||
|
||||
import * as types from "./types";
|
||||
import { getAuthConfig } from "./validation";
|
||||
|
||||
setupTests(test);
|
||||
|
||||
for (const schemaTest of types.oidcSchemas) {
|
||||
for (const includeOptional of [true, false]) {
|
||||
const minimalName = includeOptional ? "full" : "minimal";
|
||||
|
||||
test(`getAuthConfig - ${schemaTest.name} - ${minimalName}`, async (t) => {
|
||||
const config = makeFromSchema(includeOptional, schemaTest.schema);
|
||||
|
||||
t.deepEqual(
|
||||
getAuthConfig({
|
||||
...config,
|
||||
unexpected: "unexpected-value",
|
||||
} as unknown as json.UnvalidatedObject<types.AuthConfig>),
|
||||
config,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("getAuthConfig - token", async (t) => {
|
||||
const config = makeFromSchema(true, types.tokenSchema);
|
||||
|
||||
t.deepEqual(
|
||||
getAuthConfig({
|
||||
...config,
|
||||
unexpected: "unexpected-value",
|
||||
} as json.UnvalidatedObject<types.AuthConfig>),
|
||||
config,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAuthConfig - username and password", async (t) => {
|
||||
const config = makeFromSchema(true, types.usernamePasswordSchema);
|
||||
|
||||
t.deepEqual(
|
||||
getAuthConfig({
|
||||
...config,
|
||||
unexpected: "unexpected-value",
|
||||
} as json.UnvalidatedObject<types.AuthConfig>),
|
||||
config,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAuthConfig - empty", async (t) => {
|
||||
const config = makeFromSchema(false, types.usernamePasswordSchema);
|
||||
|
||||
// Since the purpose of constructing the `AuthConfig` values is for
|
||||
// serialisation to JSON so that they can be passed to the proxy as configuration,
|
||||
// we only care that the stringified JSON representations are the same.
|
||||
t.deepEqual(
|
||||
JSON.stringify(
|
||||
getAuthConfig({
|
||||
...config,
|
||||
unexpected: "unexpected-value",
|
||||
} as json.UnvalidatedObject<types.AuthConfig>),
|
||||
),
|
||||
JSON.stringify({}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import * as json from "../json";
|
||||
import { isDefined } from "../util";
|
||||
|
||||
import type { AuthConfig, UsernamePassword } from "./types";
|
||||
import * as types from "./types";
|
||||
|
||||
/** Constructs a new object from `obj` with only keys that exist in `schema`. */
|
||||
export function cloneCredential<S extends json.Schema>(
|
||||
schema: S,
|
||||
obj: json.FromSchema<S>,
|
||||
): json.FromSchema<S> {
|
||||
const result = {};
|
||||
|
||||
for (const key of Object.keys(schema)) {
|
||||
// Skip keys that don't exist or don't have a value.
|
||||
if (!isDefined(obj[key])) {
|
||||
continue;
|
||||
}
|
||||
result[key] = obj[key];
|
||||
}
|
||||
|
||||
return result as json.FromSchema<S>;
|
||||
}
|
||||
|
||||
/** Extracts an `AuthConfig` value from `config`. */
|
||||
export function getAuthConfig(
|
||||
config: json.UnvalidatedObject<AuthConfig>,
|
||||
): AuthConfig {
|
||||
// Start by checking for the OIDC configurations, since they have required properties
|
||||
// which we can use to identify them.
|
||||
for (const oidcSchema of types.oidcSchemas) {
|
||||
if (json.validateSchema(oidcSchema.schema, config)) {
|
||||
return cloneCredential(oidcSchema.schema, config);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, try the basic configuration types.
|
||||
if (types.isToken(config)) {
|
||||
// There are three scenarios for non-OIDC authentication based on the registry type:
|
||||
//
|
||||
// 1. `username`+`token`
|
||||
// 2. A `token` that combines the username and actual token, separated by ':'.
|
||||
// 3. `username`+`password`
|
||||
//
|
||||
// In all three cases, all fields are optional. If the `token` field is present,
|
||||
// we accept the configuration as a `Token` typed configuration, with the `token`
|
||||
// value and an optional `username`. Otherwise, we accept the configuration
|
||||
// typed as `UsernamePassword` (in the `else` clause below) with optional
|
||||
// username and password. I.e. a private registry type that uses 1. or 2.,
|
||||
// but has no `token` configured, will get accepted as `UsernamePassword` here.
|
||||
|
||||
if (isDefined(config.token)) {
|
||||
// Mask token to reduce chance of accidental leakage in logs, if we have one.
|
||||
core.setSecret(config.token);
|
||||
}
|
||||
|
||||
return cloneCredential(types.tokenSchema, config);
|
||||
} else {
|
||||
let username: string | undefined = undefined;
|
||||
let password: string | undefined = undefined;
|
||||
|
||||
// Both "username" and "password" are optional. If we have reached this point, we need
|
||||
// to validate which of them are present and that they have the correct type if so.
|
||||
if ("password" in config && json.isString(config.password)) {
|
||||
// Mask password to reduce chance of accidental leakage in logs, if we have one.
|
||||
core.setSecret(config.password);
|
||||
password = config.password;
|
||||
}
|
||||
if ("username" in config && json.isString(config.username)) {
|
||||
username = config.username;
|
||||
}
|
||||
|
||||
// Return the `UsernamePassword` object. Both username and password may be undefined.
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
} satisfies UsernamePassword;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user