Skip to content

Commit

Permalink
ENG-2884 Add multi-host support (#132)
Browse files Browse the repository at this point in the history
Adds multi-host support to the CLI by optionally looking up the CLI
configuration, including the backend host, from Firestore. This allows
us to specify separate backend hosts for each tenant.

- Adds an optional config to the /orgs/{orgId} document
- Upon login, If the config is found, the config in stored locally
- When executing a command, the local config is used, if found
  • Loading branch information
fabgo authored Oct 18, 2024
1 parent 819757e commit 2b0cd3d
Show file tree
Hide file tree
Showing 21 changed files with 167 additions and 72 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
},
"scripts": {
"build": "tsc && cp -r public dist/",
"test:unit": "NODE_ENV=unit jest --color",
"clean": "rm -f tsconfig.tsbuildinfo && rm -rf dist/",
"lint": "yarn prettier --check . && yarn run eslint --max-warnings 0 .",
"p0": "node --no-deprecation ./p0",
Expand Down
Binary file added src/commands/.DS_Store
Binary file not shown.
4 changes: 2 additions & 2 deletions src/commands/__tests__/__snapshots__/login.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`login organization exists should write the user's identity to the file system 1`] = `
exports[`login organization exists should write the user's identity & config to the file system 1`] = `
[
[
"/path/to/home/.p0",
"/dummy/identity/file/path",
"{
"credential": {
"access_token": "test-access-token",
Expand Down
18 changes: 16 additions & 2 deletions src/commands/__tests__/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This file is part of @p0security/cli
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { bootstrapConfig } from "../../drivers/env";
import { pluginLoginMap } from "../../plugins/login";
import { mockGetDoc } from "../../testing/firestore";
import { login } from "../login";
Expand All @@ -18,7 +19,12 @@ jest.spyOn(Date, "now").mockReturnValue(1.6e12);
jest.mock("fs/promises");
jest.mock("../../drivers/auth", () => ({
...jest.requireActual("../../drivers/auth"),
IDENTITY_FILE_PATH: "/path/to/home/.p0",
IDENTITY_FILE_PATH: "/dummy/identity/file/path",
}));
jest.mock("../../drivers/config", () => ({
...jest.requireActual("../../drivers/config"),
saveConfig: jest.fn(),
getTenantConfig: jest.fn(() => bootstrapConfig),
}));
jest.mock("../../drivers/stdio");
jest.mock("../../plugins/login");
Expand All @@ -34,6 +40,7 @@ describe("login", () => {
`"Could not find organization"`
);
});

it("should print a friendly error if unsupported login", async () => {
mockGetDoc({
slug: "test-org",
Expand All @@ -44,6 +51,7 @@ describe("login", () => {
`"Unsupported login for your organization"`
);
});

describe("organization exists", () => {
let credentialData: string = "";
mockReadFile.mockImplementation(async () =>
Expand All @@ -60,27 +68,33 @@ describe("login", () => {
},
})
);

beforeEach(() => {
credentialData = "";
jest.clearAllMocks();

mockGetDoc({
slug: "test-org",
tenantId: "test-tenant",
ssoProvider: "google",
});
});

it("should call the provider's login function", async () => {
await login({ org: "test-org" });
expect(pluginLoginMap.google).toHaveBeenCalled();
});
it("should write the user's identity to the file system", async () => {

it("should write the user's identity & config to the file system", async () => {
await login({ org: "test-org" });
expect(mockWriteFile.mock.calls).toMatchSnapshot();
});

it("validates authentication", async () => {
await login({ org: "test-org" });
expect((signInWithCredential as jest.Mock).mock.calls).toMatchSnapshot();
});

it("returns an error message if firebase cannot determine the user's email", async () => {
mockSignInWithCredential.mockResolvedValueOnce({
user: {},
Expand Down
4 changes: 2 additions & 2 deletions src/commands/allow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ You should have received a copy of the GNU General Public License along with @p0
**/
import { fetchCommand } from "../drivers/api";
import { authenticate } from "../drivers/auth";
import { guard } from "../drivers/firestore";
import { fsShutdownGuard } from "../drivers/firestore";
import { print2 } from "../drivers/stdio";
import { AllowResponse } from "../types/allow";
import { Authn } from "../types/identity";
Expand All @@ -37,7 +37,7 @@ export const allowCommand = (yargs: yargs.Argv) =>
"allow [arguments..]",
"Create standing access for a resource",
allowArgs,
guard(allow)
fsShutdownGuard(allow)
);

export const allow = async (
Expand Down
6 changes: 3 additions & 3 deletions src/commands/aws/role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ You should have received a copy of the GNU General Public License along with @p0
**/
import { parseXml } from "../../common/xml";
import { authenticate } from "../../drivers/auth";
import { guard } from "../../drivers/firestore";
import { fsShutdownGuard } from "../../drivers/firestore";
import { print1, print2 } from "../../drivers/stdio";
import { getAwsConfig } from "../../plugins/aws/config";
import { AwsFederatedLogin, AwsItem } from "../../plugins/aws/types";
Expand All @@ -29,7 +29,7 @@ export const role = (yargs: yargs.Argv<{ account: string | undefined }>) =>
"List available AWS roles",
identity,
// TODO: select based on uidLocation
guard(oktaAwsListRoles)
fsShutdownGuard(oktaAwsListRoles)
)
.command(
"assume <role>",
Expand All @@ -41,7 +41,7 @@ export const role = (yargs: yargs.Argv<{ account: string | undefined }>) =>
describe: "An AWS role name",
}),
// TODO: select based on uidLocation
guard(oktaAwsAssumeRole)
fsShutdownGuard(oktaAwsAssumeRole)
)
.demandCommand(1)
);
Expand Down
4 changes: 2 additions & 2 deletions src/commands/grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file is part of @p0security/cli
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { guard } from "../drivers/firestore";
import { fsShutdownGuard } from "../drivers/firestore";
import { request, requestArgs } from "./shared/request";
import yargs from "yargs";

Expand All @@ -17,5 +17,5 @@ export const grantCommand = (yargs: yargs.Argv) =>
"grant [arguments..]",
"Grant access to another identity",
requestArgs,
guard(request("grant"))
fsShutdownGuard(request("grant"))
);
4 changes: 2 additions & 2 deletions src/commands/kubeconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ You should have received a copy of the GNU General Public License along with @p0
import { retryWithSleep } from "../common/retry";
import { AnsiSgr } from "../drivers/ansi";
import { authenticate } from "../drivers/auth";
import { guard } from "../drivers/firestore";
import { fsShutdownGuard } from "../drivers/firestore";
import { print2, spinUntil } from "../drivers/stdio";
import { parseArn } from "../plugins/aws/utils";
import {
Expand Down Expand Up @@ -65,7 +65,7 @@ export const kubeconfigCommand = (yargs: yargs.Argv) =>
describe:
"Requested duration for access (format like '10 minutes', '2 hours', '5 days', or '1 week')",
}),
guard(kubeconfigAction)
fsShutdownGuard(kubeconfigAction)
);

const kubeconfigAction = async (
Expand Down
18 changes: 13 additions & 5 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ This file is part of @p0security/cli
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import {
authenticate,
IDENTITY_CACHE_PATH,
IDENTITY_FILE_PATH,
authenticate,
} from "../drivers/auth";
import { doc, guard } from "../drivers/firestore";
import { saveConfig } from "../drivers/config";
import { bootstrapConfig } from "../drivers/env";
import { fsShutdownGuard, publicDoc } from "../drivers/firestore";
import { print2 } from "../drivers/stdio";
import { pluginLoginMap } from "../plugins/login";
import { TokenResponse } from "../types/oidc";
Expand All @@ -32,10 +34,14 @@ export const login = async (
args: { org: string },
options?: { skipAuthenticate?: boolean }
) => {
const orgDoc = await getDoc<RawOrgData, object>(doc(`orgs/${args.org}`));
const orgDoc = await getDoc<RawOrgData, object>(
publicDoc(`orgs/${args.org}`)
);
const orgData = orgDoc.data();
if (!orgData) throw "Could not find organization";

await saveConfig(orgData.config ?? bootstrapConfig);

const orgWithSlug: OrgData = { ...orgData, slug: args.org };

const plugin = orgWithSlug?.ssoProvider;
Expand All @@ -49,7 +55,9 @@ export const login = async (
await writeIdentity(orgWithSlug, tokenResponse);

// validate auth
if (!options?.skipAuthenticate) await authenticate({ noRefresh: true });
if (!options?.skipAuthenticate) {
await authenticate();
}

print2(`You are now logged in, and can use the p0 CLI.`);
};
Expand Down Expand Up @@ -95,5 +103,5 @@ export const loginCommand = (yargs: yargs.Argv) =>
type: "string",
describe: "Your P0 organization ID",
}),
guard(login)
fsShutdownGuard(login)
);
4 changes: 2 additions & 2 deletions src/commands/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ You should have received a copy of the GNU General Public License along with @p0
import { AnsiSgr } from "../drivers/ansi";
import { fetchCommand } from "../drivers/api";
import { authenticate } from "../drivers/auth";
import { guard } from "../drivers/firestore";
import { fsShutdownGuard } from "../drivers/firestore";
import { print2, print1, spinUntil } from "../drivers/stdio";
import { max, orderBy } from "lodash";
import pluralize from "pluralize";
Expand Down Expand Up @@ -45,7 +45,7 @@ export const lsCommand = (yargs: yargs.Argv) =>
"ls [arguments..]",
"List request-command arguments",
lsArgs,
guard(ls)
fsShutdownGuard(ls)
);

const ls = async (
Expand Down
4 changes: 2 additions & 2 deletions src/commands/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file is part of @p0security/cli
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { guard } from "../drivers/firestore";
import { fsShutdownGuard } from "../drivers/firestore";
import { request, requestArgs } from "./shared/request";
import yargs from "yargs";

Expand All @@ -17,5 +17,5 @@ export const requestCommand = (yargs: yargs.Argv) =>
"request [arguments..]",
"Manually request permissions on a resource",
requestArgs,
guard(request("request"))
fsShutdownGuard(request("request"))
);
4 changes: 2 additions & 2 deletions src/commands/scp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This file is part of @p0security/cli
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { authenticate } from "../drivers/auth";
import { guard } from "../drivers/firestore";
import { fsShutdownGuard } from "../drivers/firestore";
import { sshOrScp } from "../plugins/ssh";
import { SshRequest, SupportedSshProviders } from "../types/ssh";
import { prepareRequest, ScpCommandArgs } from "./shared/ssh";
Expand Down Expand Up @@ -69,7 +69,7 @@ export const scpCommand = (yargs: yargs.Argv) =>
The '--' argument must be specified between P0-specific args on the left and SCP_ARGS on the right.`
),

guard(scpAction)
fsShutdownGuard(scpAction)
);

/** Transfers files between a local and remote hosts using SSH.
Expand Down
4 changes: 2 additions & 2 deletions src/commands/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This file is part of @p0security/cli
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { authenticate } from "../drivers/auth";
import { guard } from "../drivers/firestore";
import { fsShutdownGuard } from "../drivers/firestore";
import { sshOrScp } from "../plugins/ssh";
import { SshCommandArgs, prepareRequest } from "./shared/ssh";
import yargs from "yargs";
Expand Down Expand Up @@ -69,7 +69,7 @@ export const sshCommand = (yargs: yargs.Argv) =>
$ p0 ssh example-instance --provider gcloud -- -NR '*:8080:localhost:8088' -o 'GatewayPorts yes'`
),

guard(sshAction)
fsShutdownGuard(sshAction)
);

/** Connect to an SSH backend
Expand Down
5 changes: 2 additions & 3 deletions src/drivers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ This file is part of @p0security/cli
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { config } from "../drivers/env";
import { Authn } from "../types/identity";
import { getTenantConfig } from "./config";
import * as path from "node:path";
import yargs from "yargs";

const tenantUrl = (tenant: string) => `${config.appUrl}/o/${tenant}`;

const tenantUrl = (tenant: string) => `${getTenantConfig().appUrl}/o/${tenant}`;
const commandUrl = (tenant: string) => `${tenantUrl(tenant)}/command/`;

export const fetchCommand = async <T>(
Expand Down
29 changes: 6 additions & 23 deletions src/drivers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,9 @@ You should have received a copy of the GNU General Public License along with @p0
import { login } from "../commands/login";
import { Authn, Identity } from "../types/identity";
import { P0_PATH } from "../util";
import { auth } from "./firestore";
import { loadConfig } from "./config";
import { authenticateToFirebase, initializeFirebase } from "./firestore";
import { print2 } from "./stdio";
import {
OAuthProvider,
SignInMethod,
signInWithCredential,
} from "firebase/auth";
import * as fs from "fs/promises";
import * as path from "path";

Expand Down Expand Up @@ -96,24 +92,11 @@ export const loadCredentials = async (options?: {
export const authenticate = async (options?: {
noRefresh?: boolean;
}): Promise<Authn> => {
const identity = await loadCredentials(options);
const { credential } = identity;
await loadConfig();
initializeFirebase();

// TODO: Move to map lookup
const provider = new OAuthProvider(
identity.org.ssoProvider === "google"
? SignInMethod.GOOGLE
: identity.org.providerId
);
const firebaseCredential = provider.credential({
accessToken: credential.access_token,
idToken: credential.id_token,
});
auth.tenantId = identity.org.tenantId;
const userCredential = await signInWithCredential(auth, firebaseCredential);
if (!userCredential?.user?.email) {
throw "Can not sign in: this user has previously signed in with a different identity provider.\nPlease contact support@p0.dev to enable this user.";
}
const identity = await loadCredentials(options);
const userCredential = await authenticateToFirebase(identity);

return { userCredential, identity };
};
36 changes: 36 additions & 0 deletions src/drivers/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** Copyright © 2024-present P0 Security
This file is part of @p0security/cli
@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License.
@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { Config } from "../types/org";
import { P0_PATH } from "../util";
import { print2 } from "./stdio";
import fs from "fs/promises";
import path from "path";

export const CONFIG_FILE_PATH = path.join(P0_PATH, "config.json");

let tenantConfig: Config;

export function getTenantConfig(): Config {
return tenantConfig;
}

export async function saveConfig(config: Config) {
print2(`Saving config to ${CONFIG_FILE_PATH}.`);
const dir = path.dirname(CONFIG_FILE_PATH);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(CONFIG_FILE_PATH, JSON.stringify(config), { mode: "600" });
tenantConfig = config;
}

export async function loadConfig() {
const buffer = await fs.readFile(CONFIG_FILE_PATH);
tenantConfig = JSON.parse(buffer.toString());
}
2 changes: 1 addition & 1 deletion src/drivers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dotenv.config();

const { env } = process;

export const config = {
export const bootstrapConfig = {
fs: {
// Falls back to public production Firestore credentials
apiKey: env.P0_FS_API_KEY ?? "AIzaSyCaL-Ik_l_5tdmgNUNZ4Nv6NuR4o5_PPfs",
Expand Down
Loading

0 comments on commit 2b0cd3d

Please sign in to comment.