Skip to content

Commit a35692d

Browse files
authored
feat: add a flag to disable API key readback (#11460)
Context: https://buildbuddy-corp.slack.com/archives/C01H6DW5UFL/p1772211902400119?thread_ts=1772199877.792549&cid=C01H6DW5UFL A customer requested to be able to disable readback of API keys in the UI (i.e. make them accessible via the UI / API only once, when they are created). This seemed like a nice/reasonable security feature to add, and we might want to enable this in our cloud UI as well at some point (making it an org-level preference to start with, maybe).
1 parent 1c2c22f commit a35692d

File tree

20 files changed

+427
-61
lines changed

20 files changed

+427
-61
lines changed

app/auth/BUILD

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ ts_library(
2121
],
2222
)
2323

24+
ts_library(
25+
name = "certificate_download_link",
26+
srcs = ["certificate_download_link.tsx"],
27+
deps = [
28+
"//:node_modules/@types/react",
29+
"//:node_modules/react",
30+
"//:node_modules/tslib",
31+
"//app/components/button:link_button",
32+
"//app/components/link:blob",
33+
],
34+
)
35+
2436
ts_library(
2537
name = "user",
2638
srcs = ["user.ts"],
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from "react";
2+
import LinkButton, { LinkButtonProps } from "../components/button/link_button";
3+
import WithBlobObjectURL from "../components/link/blob";
4+
5+
export type CertificateDownloadLinkProps = Omit<LinkButtonProps, "href" | "download"> & {
6+
filename: string;
7+
contents: string;
8+
};
9+
10+
export default function CertificateDownloadLink(props: CertificateDownloadLinkProps) {
11+
const { filename, contents, ...rest } = props;
12+
return (
13+
<WithBlobObjectURL blobParts={[contents]} options={{ type: "text/plain" }}>
14+
{(href) => <LinkButton {...rest} download={filename} href={href} />}
15+
</WithBlobObjectURL>
16+
);
17+
}

app/components/banner/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ ts_library(
1212
"//:node_modules/lucide-react",
1313
"//:node_modules/react",
1414
"//:node_modules/tslib",
15+
"//app/components/button",
1516
],
1617
)

app/components/banner/banner.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
align-items: start;
66
}
77

8+
.banner .banner-content {
9+
flex-grow: 1;
10+
}
11+
12+
.banner .banner-dismiss-button {
13+
align-self: flex-start;
14+
}
15+
816
.banner.banner-info {
917
background: var(--color-banner-info-bg);
1018
color: var(--color-banner-info-text);

app/components/banner/banner.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react";
1+
import { AlertCircle, CheckCircle, Info, X, XCircle } from "lucide-react";
22
import React from "react";
3+
import { OutlinedButton } from "../button/button";
34

45
const ICONS = {
56
info: <Info className="icon blue" />,
@@ -13,18 +14,25 @@ type BannerType = keyof typeof ICONS;
1314
export type BannerProps = JSX.IntrinsicElements["div"] & {
1415
/** The banner type. */
1516
type: BannerType;
17+
/** Called when the dismiss button is clicked. If null/undefined, no dismiss button is shown. */
18+
onDismiss?: (() => void) | null;
1619
};
1720

1821
/**
1922
* A banner shows a message that draws the attention of the user, using a
2023
* colorful icon and background.
2124
*/
2225
export const Banner = React.forwardRef((props: BannerProps, ref: React.Ref<HTMLDivElement>) => {
23-
const { type = "info", className, children, ...rest } = props;
26+
const { type = "info", className, children, onDismiss, ...rest } = props;
2427
return (
2528
<div className={`banner banner-${type} ${className || ""}`} {...rest} ref={ref}>
2629
{ICONS[type]}
2730
<div className="banner-content">{children}</div>
31+
{onDismiss && (
32+
<OutlinedButton className="icon-button banner-dismiss-button" onClick={onDismiss} title="Dismiss" type="button">
33+
<X className="icon" />
34+
</OutlinedButton>
35+
)}
2836
</div>
2937
);
3038
});

app/components/link/BUILD

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ load("//rules/typescript:index.bzl", "ts_library")
22

33
package(default_visibility = ["//visibility:public"])
44

5+
ts_library(
6+
name = "blob",
7+
srcs = ["blob.tsx"],
8+
deps = [
9+
"//:node_modules/@types/react",
10+
"//:node_modules/react",
11+
],
12+
)
13+
514
ts_library(
615
name = "link",
716
srcs = ["link.tsx"],

app/components/link/blob.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React from "react";
2+
3+
export type WithBlobObjectURLProps = {
4+
/** Blob parts used to create an object URL. */
5+
blobParts: BlobPart[];
6+
/** Optional Blob options such as MIME type. */
7+
options?: BlobPropertyBag;
8+
/** Render prop that receives the generated object URL. */
9+
children: (href: string) => React.ReactNode;
10+
};
11+
12+
interface State {
13+
href: string;
14+
}
15+
16+
/**
17+
* Generates a blob-backed object URL and passes it to children via render props.
18+
*
19+
* The object URL is revoked whenever inputs change and when the component unmounts.
20+
*/
21+
export default class WithBlobObjectURL extends React.Component<WithBlobObjectURLProps, State> {
22+
state: State = {
23+
href: this.createHref(this.props),
24+
};
25+
26+
componentDidUpdate(prevProps: WithBlobObjectURLProps) {
27+
if (prevProps.blobParts === this.props.blobParts && prevProps.options === this.props.options) {
28+
return;
29+
}
30+
window.URL.revokeObjectURL(this.state.href);
31+
this.setState({ href: this.createHref(this.props) });
32+
}
33+
34+
componentWillUnmount() {
35+
window.URL.revokeObjectURL(this.state.href);
36+
}
37+
38+
private createHref(props: WithBlobObjectURLProps): string {
39+
return window.URL.createObjectURL(new Blob(props.blobParts, props.options));
40+
}
41+
42+
render() {
43+
return this.props.children(this.state.href);
44+
}
45+
}

app/docs/BUILD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ ts_library(
2323
"//:node_modules/react",
2424
"//:node_modules/tslib",
2525
"//app/auth:auth_service",
26+
"//app/auth:certificate_download_link",
2627
"//app/capabilities",
2728
"//app/components/banner",
2829
"//app/components/button:link_button",
30+
"//app/components/link",
2931
"//app/components/select",
3032
"//app/components/spinner",
3133
"//app/errors:error_service",

app/docs/setup_code.tsx

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import React from "react";
22
import { api_key } from "../../proto/api_key_ts_proto";
33
import { bazel_config } from "../../proto/bazel_config_ts_proto";
44
import authService, { User } from "../auth/auth_service";
5+
import CertificateDownloadLink from "../auth/certificate_download_link";
56
import capabilities from "../capabilities/capabilities";
67
import Banner from "../components/banner/banner";
78
import LinkButton from "../components/button/link_button";
9+
import { TextLink } from "../components/link/link";
810
import Select, { Option } from "../components/select/select";
911
import Spinner from "../components/spinner/spinner";
1012
import error_service from "../errors/error_service";
@@ -56,12 +58,18 @@ export default class SetupCodeComponent extends React.Component<Props, State> {
5658
rpcService.service
5759
.getBazelConfig(request)
5860
.then((response) => {
59-
this.fetchAPIKeyValue(response, 0);
61+
if (!this.isAPIKeyValueReadbackExplicitlyDisabled()) {
62+
this.fetchAPIKeyValue(response, 0);
63+
}
6064
this.setState({ bazelConfigResponse: response, selectedCredentialIndex: 0 });
6165
})
6266
.catch((e) => error_service.handleError(e));
6367
}
6468

69+
private isAPIKeyValueReadbackExplicitlyDisabled() {
70+
return capabilities.config.apiKeyValueReadbackEnabled === false;
71+
}
72+
6573
getSelectedCredential(): bazel_config.Credentials | null {
6674
const { bazelConfigResponse: response, selectedCredentialIndex: index } = this.state;
6775
if (!response?.credential) return null;
@@ -144,10 +152,13 @@ export default class SetupCodeComponent extends React.Component<Props, State> {
144152
if (this.state.auth == "key") {
145153
const selectedCredential = this.getSelectedCredential();
146154
if (!selectedCredential?.apiKey) return null;
155+
const value = this.isAPIKeyValueReadbackExplicitlyDisabled()
156+
? "YOUR_API_KEY_HERE"
157+
: selectedCredential.apiKey.value;
147158

148159
return (
149160
<div>
150-
<div>common --remote_header=x-buildbuddy-api-key={selectedCredential.apiKey.value}</div>
161+
<div>common --remote_header=x-buildbuddy-api-key={value}</div>
151162
</div>
152163
);
153164
}
@@ -199,6 +210,9 @@ export default class SetupCodeComponent extends React.Component<Props, State> {
199210
}
200211

201212
async fetchAPIKeyValue(bazelConfigResponse: bazel_config.IGetBazelConfigResponse, selectedIndex: number) {
213+
if (this.isAPIKeyValueReadbackExplicitlyDisabled()) {
214+
return;
215+
}
202216
this.setState({ apiKeyLoading: true });
203217
try {
204218
const creds = bazelConfigResponse.credential;
@@ -223,7 +237,9 @@ export default class SetupCodeComponent extends React.Component<Props, State> {
223237

224238
onChangeCredential(e: React.ChangeEvent<HTMLSelectElement>) {
225239
const selectedIndex = Number(e.target.value);
226-
this.fetchAPIKeyValue(this.state.bazelConfigResponse!, selectedIndex);
240+
if (!this.isAPIKeyValueReadbackExplicitlyDisabled()) {
241+
this.fetchAPIKeyValue(this.state.bazelConfigResponse!, selectedIndex);
242+
}
227243
this.setState({ selectedCredentialIndex: selectedIndex });
228244
}
229245

@@ -330,7 +346,8 @@ export default class SetupCodeComponent extends React.Component<Props, State> {
330346
</span>
331347
)}
332348

333-
{(this.state.auth === "cert" || this.state.auth === "key") &&
349+
{!this.isAPIKeyValueReadbackExplicitlyDisabled() &&
350+
(this.state.auth === "cert" || this.state.auth === "key") &&
334351
(this.state.bazelConfigResponse?.credential?.length || 0) > 1 && (
335352
<span>
336353
<Select
@@ -435,6 +452,10 @@ export default class SetupCodeComponent extends React.Component<Props, State> {
435452
</span>
436453
)}
437454
</div>
455+
<div className="setup-api-key-settings-note">
456+
To create a new API key{this.isCertEnabled() ? " or certificate" : ""}, see{" "}
457+
<TextLink href="/settings/org/api-keys">Settings</TextLink>.
458+
</div>
438459
{this.state.executionChecked && (
439460
<div className="setup-notice">
440461
<b>Note:</b> You've enabled remote execution. In addition to these .bazelrc flags, you'll also need to
@@ -478,39 +499,31 @@ export default class SetupCodeComponent extends React.Component<Props, State> {
478499
)}
479500
{this.state.auth == "cert" && (
480501
<div>
481-
<div className="downloads">
482-
{selectedCredential?.certificate?.cert && (
483-
<div>
484-
<a
485-
download="buildbuddy-cert.pem"
486-
href={window.URL.createObjectURL(
487-
new Blob([selectedCredential.certificate.cert], {
488-
type: "text/plain",
489-
})
490-
)}>
491-
Download buildbuddy-cert.pem
492-
</a>
493-
</div>
494-
)}
495-
{selectedCredential?.certificate?.key && (
496-
<div>
497-
<a
498-
download="buildbuddy-key.pem"
499-
href={window.URL.createObjectURL(
500-
new Blob([selectedCredential.certificate.key], {
501-
type: "text/plain",
502-
})
503-
)}>
504-
Download buildbuddy-key.pem
505-
</a>
502+
{!this.isAPIKeyValueReadbackExplicitlyDisabled() && (
503+
<>
504+
<div className="downloads">
505+
{selectedCredential?.certificate?.cert && (
506+
<CertificateDownloadLink
507+
filename="buildbuddy-cert.pem"
508+
contents={selectedCredential.certificate.cert}>
509+
Download buildbuddy-cert.pem
510+
</CertificateDownloadLink>
511+
)}
512+
{selectedCredential?.certificate?.key && (
513+
<CertificateDownloadLink
514+
filename="buildbuddy-key.pem"
515+
contents={selectedCredential.certificate.key}>
516+
Download buildbuddy-key.pem
517+
</CertificateDownloadLink>
518+
)}
506519
</div>
507-
)}
508-
</div>
509-
To use certificate based auth, download the two files above and place them in your workspace directory.
510-
If you place them outside of your workspace, update the paths in your{" "}
511-
<span className="code">.bazelrc</span> file to point to the correct location.
512-
<br />
513-
<br />
520+
To use certificate based auth, download the two files above and place them in your workspace
521+
directory. If you place them outside of your workspace, update the paths in your{" "}
522+
<span className="code">.bazelrc</span> file to point to the correct location.
523+
<br />
524+
<br />
525+
</>
526+
)}
514527
Note: Certificate based auth is only compatible with Bazel version 3.1 and above.
515528
</div>
516529
)}

app/root/root.css

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -810,11 +810,16 @@ code .comment {
810810
font-weight: 600;
811811
}
812812

813+
.setup .setup-api-key-settings-note {
814+
margin-bottom: 16px;
815+
color: var(--color-text-secondary);
816+
}
817+
813818
.setup-controls {
814819
display: flex;
815820
align-items: center;
816821
flex-wrap: wrap;
817-
margin-bottom: 32px;
822+
margin-bottom: 16px;
818823
/* negate 8px margin-top on children, to prevent the first row from having a top margin */
819824
margin-top: -8px;
820825
}
@@ -886,19 +891,11 @@ code .comment {
886891

887892
.setup .downloads {
888893
display: flex;
894+
gap: 8px;
889895
margin-top: 16px;
890896
margin-bottom: 16px;
891897
}
892898

893-
.setup .downloads a {
894-
background-color: var(--color-bg-inverted);
895-
color: var(--color-text-inverted);
896-
padding: 7px 16px;
897-
margin-right: 8px;
898-
border-radius: 8px;
899-
text-decoration: none;
900-
}
901-
902899
.setup-notice {
903900
padding: 16px 24px;
904901
border-radius: 8px;

0 commit comments

Comments
 (0)