Skip to content

Commit de5d334

Browse files
Add support for Authentication-only Backend API Keys (#580)
This PR adds support for a new `authentication_only` column on Backend API Keys. When this value is `true`, the auth interceptor for the Backend API service will enforce that only the `AuthenticateAPIKey` RPC can be called. All other requests will receive a permission denied response. It also adds the supporting CRUD to the backend store and console to make this work holistically. This has been tested and is working locally. <img width="545" height="413" alt="Screenshot 2025-09-10 at 10 02 30 AM" src="https://github.com/user-attachments/assets/adb0ddae-e20d-47d9-bd6c-06f670222e38" /> <img width="1571" height="701" alt="Screenshot 2025-09-10 at 10 02 37 AM" src="https://github.com/user-attachments/assets/c8791988-f209-40c0-9fb0-b088b6100537" />
1 parent e5115c7 commit de5d334

File tree

24 files changed

+676
-540
lines changed

24 files changed

+676
-540
lines changed

cmd/tesseralctl/migrations/000095_authentication_only_backend_api_keys.down.sql

Whitespace-only changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
alter table backend_api_keys
2+
add column authentication_only boolean not null default false;

console/src/gen/tesseral/backend/v1/models_pb.ts

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

console/src/pages/console/settings/api-keys/backend-api-keys/BackendApiKeyPage.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
FormMessage,
4141
} from "@/components/ui/form";
4242
import { Input } from "@/components/ui/input";
43+
import { Switch } from "@/components/ui/switch";
4344
import {
4445
deleteBackendAPIKey,
4546
getBackendAPIKey,
@@ -48,6 +49,7 @@ import {
4849

4950
const schema = z.object({
5051
displayName: z.string().min(1, "Display name is required"),
52+
authenticationOnly: z.boolean(),
5153
});
5254

5355
export function BackendApiKeyPage() {
@@ -65,6 +67,8 @@ export function BackendApiKeyPage() {
6567
resolver: zodResolver(schema),
6668
defaultValues: {
6769
displayName: getBackendApiKeyResponse?.backendApiKey?.displayName || "",
70+
authenticationOnly:
71+
getBackendApiKeyResponse?.backendApiKey?.authenticationOnly || false,
6872
},
6973
});
7074

@@ -73,6 +77,7 @@ export function BackendApiKeyPage() {
7377
id: backendApiKeyId,
7478
backendApiKey: {
7579
displayName: data.displayName,
80+
authenticationOnly: data.authenticationOnly,
7681
},
7782
});
7883
await refetch();
@@ -82,6 +87,8 @@ export function BackendApiKeyPage() {
8287
useEffect(() => {
8388
form.reset({
8489
displayName: getBackendApiKeyResponse?.backendApiKey?.displayName || "",
90+
authenticationOnly:
91+
getBackendApiKeyResponse?.backendApiKey?.authenticationOnly || false,
8592
});
8693
}, [getBackendApiKeyResponse, form]);
8794

@@ -174,6 +181,26 @@ export function BackendApiKeyPage() {
174181
</FormItem>
175182
)}
176183
/>
184+
<FormField
185+
control={form.control}
186+
name="authenticationOnly"
187+
render={({ field }) => (
188+
<FormItem>
189+
<FormLabel>Authentication Only</FormLabel>
190+
<FormDescription>
191+
If enabled, this API key can only be used for
192+
authentication.
193+
</FormDescription>
194+
<FormMessage />
195+
<FormControl>
196+
<Switch
197+
checked={field.value}
198+
onCheckedChange={field.onChange}
199+
/>
200+
</FormControl>
201+
</FormItem>
202+
)}
203+
/>
177204
</CardContent>
178205
</Card>
179206
</form>

console/src/pages/console/settings/api-keys/backend-api-keys/ListBackendApiKeysCard.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
HoverCardTrigger,
7878
} from "@/components/ui/hover-card";
7979
import { Input } from "@/components/ui/input";
80+
import { Switch } from "@/components/ui/switch";
8081
import {
8182
Table,
8283
TableBody,
@@ -216,6 +217,7 @@ export function ListBackendApiKeysCard() {
216217
</HoverCard>
217218
</div>
218219
</TableHead>
220+
<TableHead>Type</TableHead>
219221
<TableHead>Status</TableHead>
220222
<TableHead>Created</TableHead>
221223
<TableHead className="text-right">Actions</TableHead>
@@ -238,6 +240,15 @@ export function ListBackendApiKeysCard() {
238240
label="Backend API Key ID"
239241
/>
240242
</TableCell>
243+
<TableCell>
244+
{key.authenticationOnly ? (
245+
<Badge variant="secondary">
246+
Authentication Only
247+
</Badge>
248+
) : (
249+
<Badge>Full Access</Badge>
250+
)}
251+
</TableCell>
241252
<TableCell>
242253
{key.revoked ? (
243254
<Badge variant="secondary">Revoked</Badge>
@@ -418,6 +429,7 @@ function ManageBackendApiKeyButton({
418429

419430
const schema = z.object({
420431
displayName: z.string().min(1, "Display name is required"),
432+
authenticationOnly: z.boolean(),
421433
});
422434

423435
function CreateBackendApiKeyButton() {
@@ -441,6 +453,7 @@ function CreateBackendApiKeyButton() {
441453
resolver: zodResolver(schema),
442454
defaultValues: {
443455
displayName: "",
456+
authenticationOnly: false,
444457
},
445458
});
446459

@@ -463,6 +476,7 @@ function CreateBackendApiKeyButton() {
463476
const { backendApiKey } = await createBackendApiKeyMutation.mutateAsync({
464477
backendApiKey: {
465478
displayName: data.displayName,
479+
authenticationOnly: data.authenticationOnly,
466480
},
467481
});
468482
if (backendApiKey) {
@@ -513,6 +527,26 @@ function CreateBackendApiKeyButton() {
513527
</FormItem>
514528
)}
515529
/>
530+
<FormField
531+
control={form.control}
532+
name="authenticationOnly"
533+
render={({ field }) => (
534+
<FormItem>
535+
<FormLabel>Authentication Only</FormLabel>
536+
<FormDescription>
537+
If enabled, this API key can only be used for
538+
authentication.
539+
</FormDescription>
540+
<FormMessage />
541+
<FormControl>
542+
<Switch
543+
checked={field.value}
544+
onCheckedChange={field.onChange}
545+
/>
546+
</FormControl>
547+
</FormItem>
548+
)}
549+
/>
516550
</div>
517551
<DialogFooter className="mt-8">
518552
<Button variant="outline" onClick={handleCancel}>

internal/auditlog/store/queries/models.go

Lines changed: 7 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/backend/authn/interceptor/interceptor.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ var skipRPCs = []string{
2020
"/tesseral.backend.v1.BackendService/ConsoleGetConfiguration",
2121
}
2222

23+
var authenticationRPCs = []string{
24+
"/tesseral.backend.v1.BackendService/AuthenticateAPIKey",
25+
}
26+
2327
var errAuthorizationHeaderRequired = errors.New("authorization header is required")
2428

2529
var tracer = otel.Tracer("github.com/tesseral-labs/tesseral/internal/backend/authn/interceptor")
@@ -59,6 +63,10 @@ func New(s *store.Store, consoleProjectID string) connect.UnaryInterceptorFunc {
5963
return nil, fmt.Errorf("authenticate project api key: %w", err)
6064
}
6165

66+
if res.AuthenticationOnly && !reqContainsAllowedRPC(req, authenticationRPCs) {
67+
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
68+
}
69+
6270
ctx = authn.NewBackendAPIKeyContext(ctx, &authn.BackendAPIKeyContextData{
6371
BackendAPIKeyID: res.BackendAPIKeyID,
6472
ProjectID: res.ProjectID,
@@ -140,3 +148,12 @@ func authenticateAccessToken(ctx context.Context, s *store.Store, consoleProject
140148
ProjectID: projectID,
141149
}, nil
142150
}
151+
152+
func reqContainsAllowedRPC(req connect.AnyRequest, allowedRPCs []string) bool {
153+
for _, rpc := range allowedRPCs {
154+
if rpc == req.Spec().Procedure {
155+
return true
156+
}
157+
}
158+
return false
159+
}

0 commit comments

Comments
 (0)