Skip to content

Commit b13e2af

Browse files
committed
Add a flag to disable API key readback
1 parent 0b62aba commit b13e2af

File tree

13 files changed

+200
-18
lines changed

13 files changed

+200
-18
lines changed

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
align-items: start;
66
}
77

8+
.banner .banner-content {
9+
flex-grow: 1;
10+
}
11+
812
.banner.banner-info {
913
background: var(--color-banner-info-bg);
1014
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" onClick={onDismiss} title="Dismiss" type="button">
33+
<X className="icon" />
34+
</OutlinedButton>
35+
)}
2836
</div>
2937
);
3038
});

enterprise/app/api_keys/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ ts_library(
1515
"//app/alert:alert_service",
1616
"//app/auth:auth_service",
1717
"//app/capabilities",
18+
"//app/components/banner",
1819
"//app/components/button",
1920
"//app/components/dialog",
2021
"//app/components/input",

enterprise/app/api_keys/api_keys.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
padding: 8px 0;
2121
}
2222

23+
.api-keys .api-key-created-banner-content {
24+
display: flex;
25+
flex-direction: column;
26+
gap: 8px;
27+
}
28+
2329
.api-keys .api-keys-list > :not(:last-child) {
2430
margin-bottom: 8px;
2531
}

enterprise/app/api_keys/api_keys.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from "react";
33
import alert_service from "../../../app/alert/alert_service";
44
import { User } from "../../../app/auth/auth_service";
55
import capabilities from "../../../app/capabilities/capabilities";
6+
import Banner from "../../../app/components/banner/banner";
67
import FilledButton, { OutlinedButton } from "../../../app/components/button/button";
78
import Dialog, {
89
DialogBody,
@@ -42,6 +43,8 @@ interface State {
4243

4344
updateForm: FormState<api_key.UpdateApiKeyRequest>;
4445

46+
createdApiKey: api_key.ApiKey | null;
47+
4548
keyToDelete: api_key.ApiKey | null;
4649
isDeleteModalOpen: boolean;
4750
isDeleteModalSubmitting: boolean;
@@ -55,6 +58,8 @@ const INITIAL_STATE: State = {
5558

5659
updateForm: newFormState(api_key.UpdateApiKeyRequest.create()),
5760

61+
createdApiKey: null,
62+
5863
keyToDelete: null,
5964
isDeleteModalOpen: false,
6065
isDeleteModalSubmitting: false,
@@ -161,11 +166,14 @@ export default class ApiKeysComponent extends React.Component<ApiKeysComponentPr
161166

162167
try {
163168
this.setState({ createForm: { ...this.state.createForm, isSubmitting: true } });
164-
await this.props.create(
169+
const response = await this.props.create(
165170
new api_key.CreateApiKeyRequest({
166171
...this.state.createForm.request,
167172
})
168173
);
174+
if (!this.apiKeyValueReadbackEnabled()) {
175+
this.setState({ createdApiKey: response.apiKey ?? null });
176+
}
169177
} catch (e) {
170178
this.setState({ createForm: { ...this.state.createForm, isSubmitting: false } });
171179
errorService.handleError(e);
@@ -255,6 +263,10 @@ export default class ApiKeysComponent extends React.Component<ApiKeysComponentPr
255263
onChange(e.target.name, e.target.value);
256264
}
257265

266+
private onDismissCreatedApiKey() {
267+
this.setState({ createdApiKey: null });
268+
}
269+
258270
private onSelectReadOnly(onChange: (name: string, value: any) => any) {
259271
onChange("capability", []);
260272
}
@@ -301,6 +313,10 @@ export default class ApiKeysComponent extends React.Component<ApiKeysComponentPr
301313
return this.props.userOwnedOnly || this.props.user.canCall("updateApiKey");
302314
}
303315

316+
private apiKeyValueReadbackEnabled(): boolean {
317+
return capabilities.config.apiKeyValueReadbackEnabled !== false;
318+
}
319+
304320
private renderModal<T extends ApiKeyFields>({
305321
title,
306322
submitLabel,
@@ -458,7 +474,16 @@ export default class ApiKeysComponent extends React.Component<ApiKeysComponentPr
458474
render() {
459475
if (!this.props.user) return <></>;
460476

461-
const { keyToDelete, createForm, updateForm, getApiKeysResponse, isDeleteModalOpen, initialLoadError } = this.state;
477+
const {
478+
keyToDelete,
479+
createForm,
480+
updateForm,
481+
createdApiKey,
482+
getApiKeysResponse,
483+
isDeleteModalOpen,
484+
initialLoadError,
485+
} = this.state;
486+
const apiKeyValueReadbackEnabled = this.apiKeyValueReadbackEnabled();
462487

463488
if (!getApiKeysResponse) {
464489
return (
@@ -484,6 +509,17 @@ export default class ApiKeysComponent extends React.Component<ApiKeysComponentPr
484509
</FilledButton>
485510
</div>
486511
)}
512+
{createdApiKey?.value && (
513+
<Banner type="info" className="api-key-created-banner" onDismiss={this.onDismissCreatedApiKey.bind(this)}>
514+
<div className="api-key-created-banner-content">
515+
<div>
516+
Created API key <b title={createdApiKey.label || undefined}>{createdApiKey.label || "Untitled key"}</b>
517+
{apiKeyValueReadbackEnabled ? "." : " (save this value - you will not be able to access it later)."}
518+
</div>
519+
<ApiKeyField apiKey={createdApiKey} />
520+
</div>
521+
</Banner>
522+
)}
487523

488524
{this.renderModal({
489525
title: "New API key",
@@ -524,7 +560,7 @@ export default class ApiKeysComponent extends React.Component<ApiKeysComponentPr
524560
title={key.visibleToDevelopers ? "Visible to non-admin members of this organization" : undefined}>
525561
<span>{describeCapabilities(key)}</span>
526562
</div>
527-
<ApiKeyField apiKey={key} />
563+
{apiKeyValueReadbackEnabled && <ApiKeyField apiKey={key} />}
528564
{this.props.user.canCall(this.props.userOwnedOnly ? "updateUserApiKey" : "updateApiKey") && (
529565
<OutlinedButton className="api-key-edit-button" onClick={this.onClickUpdate.bind(this, key)}>
530566
Edit

enterprise/app/settings/settings.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export default class SettingsComponent extends React.Component<SettingsProps> {
104104
}
105105

106106
const activeTabId = this.getActiveTabId();
107+
const apiKeyValueReadbackEnabled = capabilities.config.apiKeyValueReadbackEnabled !== false;
107108

108109
return (
109110
<div className="settings">
@@ -305,6 +306,12 @@ export default class SettingsComponent extends React.Component<SettingsProps> {
305306
<div className="settings-option-description">
306307
API keys grant access to your BuildBuddy organization.
307308
</div>
309+
{!apiKeyValueReadbackEnabled && (
310+
<div className="settings-option-description">
311+
API keys cannot be viewed from this page. If you've lost access to an API key value, you can
312+
delete and recreate the key with the same capabilities.
313+
</div>
314+
)}
308315
{this.isCLILogin() && (
309316
<>
310317
<div className="settings-option-description" debug-id="cli-login-settings-banner">
@@ -340,6 +347,12 @@ export default class SettingsComponent extends React.Component<SettingsProps> {
340347
Personal API keys associate builds with both your individual user account and your
341348
organization.
342349
</div>
350+
{!apiKeyValueReadbackEnabled && (
351+
<div className="settings-option-description">
352+
API keys cannot be viewed from this page. If you've lost access to an API key value, you can
353+
delete and recreate the key with the same capabilities.
354+
</div>
355+
)}
343356
{this.isCLILogin() && (
344357
<div className="settings-option-description">
345358
<Banner type="info">

enterprise/server/backends/authdb/authdb.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,12 @@ const (
5555
)
5656

5757
var (
58-
userOwnedKeysEnabled = flag.Bool("app.user_owned_keys_enabled", false, "If true, enable user-owned API keys.")
59-
apiKeyGroupCacheTTL = flag.Duration("auth.api_key_group_cache_ttl", 5*time.Minute, "TTL for API Key to Group caching. Set to '0' to disable cache.")
60-
apiKeyEncryptionKey = flag.String("auth.api_key_encryption.key", "", "Base64-encoded 256-bit encryption key for API keys.", flag.Secret)
61-
encryptNewKeys = flag.Bool("auth.api_key_encryption.encrypt_new_keys", false, "If enabled, all new API keys will be written in an encrypted format.")
62-
encryptOldKeys = flag.Bool("auth.api_key_encryption.encrypt_old_keys", false, "If enabled, all existing unencrypted keys will be encrypted on startup. The unencrypted keys will remain in the database and will need to be cleared manually after verifying the success of the migration.")
58+
userOwnedKeysEnabled = flag.Bool("app.user_owned_keys_enabled", false, "If true, enable user-owned API keys.")
59+
apiKeyValueReadbackConfig = flag.Bool("app.api_key_value_readback_enabled", true, "If true, API key values can be retrieved after creation via API key read methods.")
60+
apiKeyGroupCacheTTL = flag.Duration("auth.api_key_group_cache_ttl", 5*time.Minute, "TTL for API Key to Group caching. Set to '0' to disable cache.")
61+
apiKeyEncryptionKey = flag.String("auth.api_key_encryption.key", "", "Base64-encoded 256-bit encryption key for API keys.", flag.Secret)
62+
encryptNewKeys = flag.Bool("auth.api_key_encryption.encrypt_new_keys", false, "If enabled, all new API keys will be written in an encrypted format.")
63+
encryptOldKeys = flag.Bool("auth.api_key_encryption.encrypt_old_keys", false, "If enabled, all existing unencrypted keys will be encrypted on startup. The unencrypted keys will remain in the database and will need to be cleared manually after verifying the success of the migration.")
6364
)
6465

6566
type apiKeyGroupCacheEntry struct {
@@ -361,6 +362,10 @@ func (d *AuthDB) fillDecryptedAPIKey(ak *tables.APIKey) error {
361362
return nil
362363
}
363364

365+
func apiKeyValueReadbackEnabled() bool {
366+
return *apiKeyValueReadbackConfig
367+
}
368+
364369
func (d *AuthDB) fillChildGroupIDs(ctx context.Context, akg *apiKeyGroup) error {
365370
// If the group is not designated as a parent then don't bother doing a
366371
// query.
@@ -839,8 +844,10 @@ func (d *AuthDB) GetAPIKey(ctx context.Context, apiKeyID string) (*tables.APIKey
839844
}
840845
}
841846

842-
if err := d.fillDecryptedAPIKey(key); err != nil {
843-
return nil, err
847+
if apiKeyValueReadbackEnabled() {
848+
if err := d.fillDecryptedAPIKey(key); err != nil {
849+
return nil, err
850+
}
844851
}
845852
return key, nil
846853
}
@@ -917,8 +924,10 @@ func (d *AuthDB) GetAPIKeys(ctx context.Context, groupID string) ([]*tables.APIK
917924

918925
keys := make([]*tables.APIKey, 0)
919926
err = db.ScanEach(rq, func(ctx context.Context, k *tables.APIKey) error {
920-
if err := d.fillDecryptedAPIKey(k); err != nil {
921-
return err
927+
if apiKeyValueReadbackEnabled() {
928+
if err := d.fillDecryptedAPIKey(k); err != nil {
929+
return err
930+
}
922931
}
923932
keys = append(keys, k)
924933
return nil
@@ -1064,8 +1073,10 @@ func (d *AuthDB) GetUserAPIKeys(ctx context.Context, userID, groupID string) ([]
10641073
rq := d.h.NewQuery(ctx, "authdb_get_user_api_keys").Raw(queryStr, args...)
10651074
var keys []*tables.APIKey
10661075
err = db.ScanEach(rq, func(ctx context.Context, k *tables.APIKey) error {
1067-
if err := d.fillDecryptedAPIKey(k); err != nil {
1068-
return err
1076+
if apiKeyValueReadbackEnabled() {
1077+
if err := d.fillDecryptedAPIKey(k); err != nil {
1078+
return err
1079+
}
10691080
}
10701081
keys = append(keys, k)
10711082
return nil
@@ -1076,3 +1087,9 @@ func (d *AuthDB) GetUserAPIKeys(ctx context.Context, userID, groupID string) ([]
10761087
func (d *AuthDB) GetUserOwnedKeysEnabled() bool {
10771088
return *userOwnedKeysEnabled
10781089
}
1090+
1091+
// GetAPIKeyValueReadbackEnabled returns whether API key values can be fetched
1092+
// via API key read methods after creation.
1093+
func (d *AuthDB) GetAPIKeyValueReadbackEnabled() bool {
1094+
return apiKeyValueReadbackEnabled()
1095+
}

enterprise/server/backends/authdb/authdb_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,71 @@ func TestUserKeyExpiration(t *testing.T) {
281281
require.NotContains(t, apiKeyIDs(keys), rsp.GetApiKey().GetId())
282282
}
283283

284+
func TestAPIKeyValueReadbackDisabled(t *testing.T) {
285+
flags.Set(t, "app.api_key_value_readback_enabled", false)
286+
287+
ctx := context.Background()
288+
env := setupEnv(t)
289+
290+
users := enterprise_testauth.CreateRandomGroups(t, env)
291+
// Get a random admin user.
292+
var admin *tables.User
293+
for _, u := range users {
294+
if u.Groups[0].HasCapability(cappb.Capability_ORG_ADMIN) {
295+
admin = u
296+
break
297+
}
298+
}
299+
require.NotNil(t, admin)
300+
301+
auth := env.GetAuthenticator().(*testauth.TestAuthenticator)
302+
adminCtx, err := auth.WithAuthenticatedUser(ctx, admin.UserID)
303+
require.NoError(t, err)
304+
305+
groupID := admin.Groups[0].Group.GroupID
306+
307+
orgKeyRsp, err := env.GetBuildBuddyServer().CreateApiKey(adminCtx, &akpb.CreateApiKeyRequest{
308+
RequestContext: &ctxpb.RequestContext{GroupId: groupID},
309+
Label: "org-key",
310+
})
311+
require.NoError(t, err)
312+
require.NotEmpty(t, orgKeyRsp.GetApiKey().GetValue())
313+
// Key remains usable for authentication even when API key value readback is disabled.
314+
_, err = env.GetAuthDB().GetAPIKeyGroupFromAPIKey(ctx, orgKeyRsp.GetApiKey().GetValue())
315+
require.NoError(t, err)
316+
317+
orgKeyGetRsp, err := env.GetBuildBuddyServer().GetApiKey(adminCtx, &akpb.GetApiKeyRequest{
318+
ApiKeyId: orgKeyRsp.GetApiKey().GetId(),
319+
})
320+
require.NoError(t, err)
321+
// Read APIs should still return metadata, but the key value should be omitted.
322+
require.Empty(t, orgKeyGetRsp.GetApiKey().GetValue())
323+
324+
// Enable user-owned keys.
325+
g, err := env.GetUserDB().GetGroupByID(adminCtx, groupID)
326+
require.NoError(t, err)
327+
g.UserOwnedKeysEnabled = true
328+
_, err = env.GetUserDB().UpdateGroup(adminCtx, g)
329+
require.NoError(t, err)
330+
331+
userKeyRsp, err := env.GetBuildBuddyServer().CreateUserApiKey(adminCtx, &akpb.CreateApiKeyRequest{
332+
RequestContext: &ctxpb.RequestContext{GroupId: groupID},
333+
Label: "user-key",
334+
})
335+
require.NoError(t, err)
336+
require.NotEmpty(t, userKeyRsp.GetApiKey().GetValue())
337+
// User-owned key remains usable for authentication even when API key value readback is disabled.
338+
_, err = env.GetAuthDB().GetAPIKeyGroupFromAPIKey(ctx, userKeyRsp.GetApiKey().GetValue())
339+
require.NoError(t, err)
340+
341+
userKeyGetRsp, err := env.GetBuildBuddyServer().GetUserApiKey(adminCtx, &akpb.GetApiKeyRequest{
342+
ApiKeyId: userKeyRsp.GetApiKey().GetId(),
343+
})
344+
require.NoError(t, err)
345+
// Same behavior for user-owned keys: metadata is returned, value is omitted.
346+
require.Empty(t, userKeyGetRsp.GetApiKey().GetValue())
347+
}
348+
284349
func TestGetAPIKeyGroupFromAPIKey(t *testing.T) {
285350
for _, encrypt := range []bool{false, true} {
286351
t.Run(fmt.Sprintf("encrypt_%t", encrypt), func(t *testing.T) {

proto/config.proto

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ message FrontendConfig {
196196

197197
// Whether dark mode UI is enabled.
198198
bool dark_mode_enabled = 65;
199+
200+
// Whether API key values can be retrieved after they are created.
201+
// If false, API key metadata should still be accessible, but values should
202+
// be omitted from read APIs.
203+
// If this field is absent, clients should treat it as true.
204+
optional bool api_key_value_readback_enabled = 66;
199205
}
200206

201207
message Region {

0 commit comments

Comments
 (0)