Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions api/v1/server/authn/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,54 @@ func (a *AuthN) handleBearerAuth(c echo.Context) error {
return fmt.Errorf("tenant not found in context")
}

token, err := getBearerTokenFromRequest(c.Request())
token, isExchangeToken, err := getBearerTokenFromRequest(c.Request())

if err != nil {
a.l.Debug().Err(err).Msg("error getting bearer token from request")

return forbidden
}

if isExchangeToken {
if a.config.Auth.ExchangeTokenClient == nil {
a.l.Error().Msgf("exchange token client is not configured")

return forbidden
}

tenantId, userId, validationErr := a.config.Auth.ExchangeTokenClient.ValidateExchangeToken(c.Request().Context(), token)

if validationErr != nil {
a.l.Debug().Err(validationErr).Msg("error validating exchange token")

return forbidden
}

if tenantId != queriedTenant.ID {
a.l.Debug().Msgf("tenant id in token does not match tenant id in context")

return forbidden
}

user, getUserErr := a.config.V1.User().GetUserByID(c.Request().Context(), userId)

if getUserErr != nil {
a.l.Debug().Err(getUserErr).Msg("error getting user by id from exchange token")

if errors.Is(getUserErr, pgx.ErrNoRows) {
return forbidden
}

return fmt.Errorf("error getting user by id from exchange token: %w", getUserErr)
}

// important: user is validated later in the authz step
c.Set("user", user)
c.Set("is_exchange_token", true)

return nil
}

// Validate the token.
tenantId, _, err := a.config.Auth.JWTManager.ValidateTenantToken(c.Request().Context(), token)

Expand Down Expand Up @@ -246,15 +286,20 @@ func (a *AuthN) handleCustomAuth(c echo.Context) error {

var errInvalidAuthHeader = fmt.Errorf("invalid authorization header in request")

func getBearerTokenFromRequest(r *http.Request) (string, error) {
func getBearerTokenFromRequest(r *http.Request) (token string, isExchangeToken bool, err error) {
reqToken := r.Header.Get("Authorization")
splitToken := strings.Split(reqToken, "Bearer")

if len(splitToken) != 2 {
return "", errInvalidAuthHeader
return "", false, errInvalidAuthHeader
}

reqToken = strings.TrimSpace(splitToken[1])

return reqToken, nil
// if there's also an X-Exchange-Token header, then this is an exchange token request
if r.Header.Get("X-Exchange-Token") != "" {
return reqToken, true, nil
}

return reqToken, false, nil
}
95 changes: 58 additions & 37 deletions api/v1/server/authz/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,47 +59,13 @@ func (a *AuthZ) authorize(c echo.Context, r *middleware.RouteInfo) error {
}

func (a *AuthZ) handleCookieAuth(c echo.Context, r *middleware.RouteInfo) error {
unauthorized := echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to view this resource")

if err := a.ensureVerifiedEmail(c, r); err != nil {
a.l.Debug().Err(err).Msgf("error ensuring verified email")
return echo.NewHTTPError(http.StatusUnauthorized, "Please verify your email before continuing")
}

// if tenant is set in the context, verify that the user is a member of the tenant
if tenant, ok := c.Get("tenant").(*sqlcv1.Tenant); ok {
user, ok := c.Get("user").(*sqlcv1.User)

if !ok {
a.l.Debug().Msgf("user not found in context")

return unauthorized
}

// check if the user is a member of the tenant
tenantMember, err := a.config.V1.Tenant().GetTenantMemberByUserID(c.Request().Context(), tenant.ID, user.ID)

if err != nil {
a.l.Debug().Err(err).Msgf("error getting tenant member")

return unauthorized
}

if tenantMember == nil {
a.l.Debug().Msgf("user is not a member of the tenant")

return unauthorized
}

// set the tenant member in the context
c.Set("tenant-member", tenantMember)

// authorize tenant operations
if err := a.authorizeTenantOperations(tenant, tenantMember, r); err != nil {
a.l.Debug().Err(err).Msgf("error authorizing tenant operations")

return unauthorized
}
if err := a.validateUserTenantPermissions(c, r); err != nil {
return err
}

if a.config.Auth.CustomAuthenticator != nil {
Expand All @@ -119,6 +85,19 @@ var restrictedWithBearerToken = []string{
// At the moment, there's no further bearer auth because bearer tokens are admin-scoped
// and we check that the bearer token has access to the tenant in the authn step.
func (a *AuthZ) handleBearerAuth(c echo.Context, r *middleware.RouteInfo) error {
// check for is_exchange_token set in the context, in which case we need to validate the user set in the context
if isExchangeToken, ok := c.Get("is_exchange_token").(bool); ok && isExchangeToken {
if err := a.ensureVerifiedEmail(c, r); err != nil {
a.l.Debug().Err(err).Msgf("error ensuring verified email for exchange token user")
return echo.NewHTTPError(http.StatusUnauthorized, "Please verify your email before continuing")
}

if err := a.validateUserTenantPermissions(c, r); err != nil {
a.l.Debug().Err(err).Msgf("error validating user tenant permissions for exchange token user")
return echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to view this resource")
}
}

if operationIn(r.OperationID, restrictedWithBearerToken) {
return echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to perform this operation")
}
Expand All @@ -143,7 +122,7 @@ func (a *AuthZ) ensureVerifiedEmail(c echo.Context, r *middleware.RouteInfo) err
user, ok := c.Get("user").(*sqlcv1.User)

if !ok {
return nil
return fmt.Errorf("user not found in context")
}

if operationIn(r.OperationID, permittedWithUnverifiedEmail) {
Expand All @@ -157,6 +136,48 @@ func (a *AuthZ) ensureVerifiedEmail(c echo.Context, r *middleware.RouteInfo) err
return nil
}

func (a *AuthZ) validateUserTenantPermissions(c echo.Context, r *middleware.RouteInfo) error {
unauthorized := echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to view this resource")

// if tenant is set in the context, verify that the user is a member of the tenant
if tenant, ok := c.Get("tenant").(*sqlcv1.Tenant); ok {
user, ok := c.Get("user").(*sqlcv1.User)

if !ok {
a.l.Debug().Msgf("user not found in context")

return unauthorized
}

// check if the user is a member of the tenant
tenantMember, err := a.config.V1.Tenant().GetTenantMemberByUserID(c.Request().Context(), tenant.ID, user.ID)

if err != nil {
a.l.Debug().Err(err).Msgf("error getting tenant member")

return unauthorized
}

if tenantMember == nil {
a.l.Debug().Msgf("user is not a member of the tenant")

return unauthorized
}

// set the tenant member in the context
c.Set("tenant-member", tenantMember)

// authorize tenant operations
if err := a.authorizeTenantOperations(tenant, tenantMember, r); err != nil {
a.l.Debug().Err(err).Msgf("error authorizing tenant operations")

return unauthorized
}
}

return nil
}

var adminAndOwnerOnly = []string{
"TenantInviteList",
"TenantInviteCreate",
Expand Down
3 changes: 3 additions & 0 deletions api/v1/server/headers/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
func Middleware() middleware.MiddlewareFunc {
return func(r *middleware.RouteInfo) echo.HandlerFunc {
return func(c echo.Context) error {
// TODO-CONTROL-PLANE: this needs to be set based on an env var, and should case on the origin I think...
c.Response().Header().Set("Access-Control-Allow-Origin", "*")

// ensure the Strict-Transport-Security header is set for all
// endpoints, as it will help ensure protection against TLS protocol downgrade
// attacks and cookie hijacking. The header also ensures that browsers only serve
Expand Down
16 changes: 16 additions & 0 deletions api/v1/server/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ func (t *APIServer) getCoreEchoService() (*echo.Echo, error) {

g := e.Group("")

// TODO-CONTROL-PLANE: add casing here, temporary allow all OPTIONS requests
g.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().Method == http.MethodOptions {
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
c.Response().Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, x-exchange-token")

return c.NoContent(http.StatusOK)
}

return next(c)
}
})

if _, err := t.registerSpec(g, oaspec); err != nil {
return nil, err
}
Expand Down Expand Up @@ -653,6 +668,7 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T) (*populator.Po
return nil, err
}
mw.Use(headers.Middleware())

mw.Use(populatorMW.Middleware)
mw.Use(authnMW.Middleware)
mw.Use(authzMW.Middleware)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export interface ParsedLogQuery {
errors: string[];
}

export interface AutocompleteSuggestion
extends SearchSuggestion<'key' | 'value'> {
export interface AutocompleteSuggestion extends SearchSuggestion<
'key' | 'value'
> {
type: 'key' | 'value';
label: string;
value: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
} from '@radix-ui/react-icons';
import { Column } from '@tanstack/react-table';

interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
interface DataTableColumnHeaderProps<
TData,
TValue,
> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import { Input } from '@/components/v1/ui/input';
import { cn } from '@/lib/utils';
import React from 'react';

interface TimePickerInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
interface TimePickerInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
picker: TimePickerType;
date: Date | undefined;
setDate: (date: Date | undefined) => void;
Expand Down
17 changes: 11 additions & 6 deletions frontend/app/src/components/v1/nav/top-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ import {
import { Separator } from '@/components/v1/ui/separator';
import { useBreadcrumbs } from '@/hooks/use-breadcrumbs';
import { usePendingInvites } from '@/hooks/use-pending-invites';
import { useOrganizations } from '@/hooks/use-organizations';
import { useTenantDetails } from '@/hooks/use-tenant';
import { useTenantHomeRoute } from '@/hooks/use-tenant-home-route';
import api, { TenantMember, User } from '@/lib/api';
import { TenantMember, User } from '@/lib/api';
import { useUserApi } from '@/lib/api/user-wrapper';
import { useApiError } from '@/lib/hooks';
import { cn } from '@/lib/utils';
import useCloud from '@/pages/auth/hooks/use-cloud';
import { appRoutes } from '@/router';
import { useMutation } from '@tanstack/react-query';
import {
Expand Down Expand Up @@ -64,11 +65,12 @@ function AccountDropdown({ user }: { user?: User }) {

// Check for pending invites to show the Invites menu item
const { pendingInvitesQuery } = usePendingInvites();
const userApi = useUserApi();

const logoutMutation = useMutation({
mutationKey: ['user:update:logout'],
mutationFn: async () => {
await api.userUpdateLogout();
await userApi.userUpdateLogout();
},
onSuccess: () => {
navigate({ to: appRoutes.authLoginRoute.to });
Expand Down Expand Up @@ -275,10 +277,13 @@ export default function TopNav({ user, tenantMemberships }: TopNavProps) {
setStoredCollapsed(!storedCollapsed);
};

const { isCloudEnabled } = useCloud();
const { isCloudEnabled, isControlPlaneEnabled, hasOrganizations } =
useOrganizations();
const { tenant } = useTenantDetails();
const showTenantSwitcher =
!!user && tenantMemberships?.length > 0 && !!tenant;
const showOrganizationSelector =
isCloudEnabled || isControlPlaneEnabled || hasOrganizations;

return (
<header className="z-50 h-16 w-full bg-background">
Expand All @@ -305,7 +310,7 @@ export default function TopNav({ user, tenantMemberships }: TopNavProps) {

<div className="flex ml-auto items-center justify-end gap-2">
{showTenantSwitcher &&
(isCloudEnabled ? (
(showOrganizationSelector ? (
<OrganizationSelector memberships={tenantMemberships} />
) : (
<TenantSwitcher memberships={tenantMemberships} />
Expand Down Expand Up @@ -398,7 +403,7 @@ export default function TopNav({ user, tenantMemberships }: TopNavProps) {
}
/>
{showTenantSwitcher &&
(isCloudEnabled ? (
(showOrganizationSelector ? (
<OrganizationSelector memberships={tenantMemberships} />
) : (
<TenantSwitcher memberships={tenantMemberships} />
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/src/components/v1/shared/duration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ const durationVariants = cva('text-sm', {
});

interface DurationProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof durationVariants> {
start?: string | Date | null;
end?: string | Date | null;
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/src/components/v1/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const badgeVariants = cva(
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/src/components/v1/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const buttonVariants = cva(
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
hoverText?: string;
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/src/components/v1/ui/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const cardVariants = cva('rounded-lg border', {
});

export interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}

const Card = React.forwardRef<HTMLDivElement, CardProps>(
Expand Down
3 changes: 1 addition & 2 deletions frontend/app/src/components/v1/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { cn } from '@/lib/utils';
import * as React from 'react';

interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
Expand Down
Loading