Skip to content

Commit

Permalink
[server, dashboard] Introduce multi-org (behind feature flag) (#20431)
Browse files Browse the repository at this point in the history
* [server config] Introduce isDedicatedInstallation, and use it to replace isSIngleOrgInstallation

incl. further cleanup around getConfiguration and server config

* [server, dashboard] Remove enableDedicatedOnboardingFlow feature flag and replace is with getInstallationConfiguration.IsDedicatedInstallation

* [dashboard, server] Remove "sinlgeOrgMode"

* [server] OrganizationService: block createTeam consistently for org-owned users

* [server, dashboard] Introduce "enable_multi_org" feature flag to allow admin-user to create organizations

* [dashboard] introduce "/?orgSlug=", which allows to pre-select an org in a "create workspace" URL (e.g. "/?orgSlug=org1#github.com/my/repo")

* [db] Auto-delete container "test-mysql" if it's already present

* fix tests

* [dashboard] Check if localStorage is available before using it

* [dashboard] SSOLogin: fix orgSlug source precedence to: path/search/localStorage

* [server] Deny "joinOrganization" for org-owned users

* Gpl/970-multi-org-tests (#20436)

* fix tests for real

* [server] Create OrgService.createOrgOwnedUser, and use that across tests to fix the "can't join org" permission issues

* Update components/server/src/orgs/organization-service.ts

Co-authored-by: Filip Troníček <filip@gitpod.io>

---------

Co-authored-by: Filip Troníček <filip@gitpod.io>

---------

Co-authored-by: Filip Troníček <filip@gitpod.io>
  • Loading branch information
geropl and filiptronicek authored Dec 9, 2024
1 parent 5bb738a commit 7f43d48
Show file tree
Hide file tree
Showing 48 changed files with 2,742 additions and 621 deletions.
5 changes: 1 addition & 4 deletions components/dashboard/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,7 @@ const LoginContent = ({
</LoginButton>
))
)}
<SSOLoginForm
onSuccess={authorizeSuccessful}
singleOrgMode={!!authProviders.data && authProviders.data.length === 0}
/>
<SSOLoginForm onSuccess={authorizeSuccessful} />
</div>
{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/src/data/featureflag-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ const featureFlags = {
// Default to true to enable on gitpod dedicated until ff support is added for dedicated
orgGitAuthProviders: true,
userGitAuthProviders: false,
enableDedicatedOnboardingFlow: false,
// Local SSH feature of VS Code Desktop Extension
gitpod_desktop_use_local_ssh_proxy: false,
enabledOrbitalDiscoveries: "",
// dummy specified dataops feature, default false
dataops: false,
enable_multi_org: false,
showBrowserExtensionPromotion: false,
enable_experimental_jbtb: false,
enabled_configuration_prebuild_full_clone: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,14 @@ export const useInstallationDefaultWorkspaceImageQuery = () => {
},
});
};

export const useInstallationConfiguration = () => {
return useQuery({
queryKey: ["installation-configuration"],
staleTime: 1000 * 60 * 10, // 10 minute
queryFn: async () => {
const response = await installationClient.getInstallationConfiguration({});
return response.configuration;
},
});
};
23 changes: 22 additions & 1 deletion components/dashboard/src/data/organizations/orgs-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,31 @@ export function useCurrentOrg(): { data?: Organization; isLoading: boolean } {
return { data: undefined, isLoading: true };
}
let orgId = localStorage.getItem("active-org");
let org: Organization | undefined = undefined;
if (orgId) {
org = orgs.data.find((org) => org.id === orgId);
}

// 1. Check for org slug
const orgSlugParam = getOrgSlugFromQuery(location.search);
if (orgSlugParam) {
org = orgs.data.find((org) => org.slug === orgSlugParam);
}

// 2. Check for org id
// id is more speficic than slug, so it takes precedence
const orgIdParam = new URLSearchParams(location.search).get("org");
if (orgIdParam) {
orgId = orgIdParam;
org = orgs.data.find((org) => org.id === orgId);
}
let org = orgs.data.find((org) => org.id === orgId);

// 3. Fallback: pick the first org
if (!org) {
org = orgs.data[0];
}

// Persist the selected org
if (org) {
localStorage.setItem("active-org", org.id);
} else if (orgId && (orgs.isLoading || orgs.isStale)) {
Expand All @@ -79,3 +96,7 @@ export function useCurrentOrg(): { data?: Organization; isLoading: boolean } {
}
return { data: org, isLoading: false };
}

export function getOrgSlugFromQuery(search: string): string | undefined {
return new URLSearchParams(search).get("orgSlug") || undefined;
}
15 changes: 8 additions & 7 deletions components/dashboard/src/dedicated-setup/use-needs-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
*/

import { useQuery } from "@tanstack/react-query";
import { useFeatureFlag } from "../data/featureflag-query";
import { noPersistence } from "../data/setup";
import { installationClient } from "../service/public-api";
import { GetOnboardingStateRequest } from "@gitpod/public-api/lib/gitpod/v1/installation_pb";
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";

/**
* @description Returns a flage stating if the current installation still needs setup before it can be used. Also returns an isLoading indicator as the check is async
*/
export const useNeedsSetup = () => {
const { data: onboardingState, isLoading } = useOnboardingState();
const enableDedicatedOnboardingFlow = useFeatureFlag("enableDedicatedOnboardingFlow");
const { data: installationConfig } = useInstallationConfiguration();
const isDedicatedInstallation = !!installationConfig?.isDedicatedInstallation;

// This needs to only be true if we've loaded the onboarding state
let needsSetup = !isLoading && onboardingState && onboardingState.completed !== true;
Expand All @@ -25,14 +26,14 @@ export const useNeedsSetup = () => {
}

return {
needsSetup: enableDedicatedOnboardingFlow && needsSetup,
needsSetup: isDedicatedInstallation && needsSetup,
// disabled queries stay in `isLoading` state, so checking feature flag here too
isLoading: enableDedicatedOnboardingFlow && isLoading,
isLoading: isDedicatedInstallation && isLoading,
};
};

const useOnboardingState = () => {
const enableDedicatedOnboardingFlow = useFeatureFlag("enableDedicatedOnboardingFlow");
export const useOnboardingState = () => {
const { data: installationConfig } = useInstallationConfiguration();

return useQuery(
noPersistence(["onboarding-state"]),
Expand All @@ -42,7 +43,7 @@ const useOnboardingState = () => {
},
{
// Only query if feature flag is enabled
enabled: enableDedicatedOnboardingFlow,
enabled: !!installationConfig?.isDedicatedInstallation,
},
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
*/

import { useQueryParams } from "../hooks/use-query-params";
import { useFeatureFlag } from "../data/featureflag-query";
import { useCallback, useState } from "react";
import { isCurrentHostExcludedFromSetup, useNeedsSetup } from "./use-needs-setup";
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";

const FORCE_SETUP_PARAM = "dedicated-setup";
const FORCE_SETUP_PARAM_VALUE = "force";
Expand All @@ -21,7 +21,8 @@ export const useShowDedicatedSetup = () => {
// again in case onboarding state isn't updated right away
const [inProgress, setInProgress] = useState(false);

const enableDedicatedOnboardingFlow = useFeatureFlag("enableDedicatedOnboardingFlow");
const { data: installationConfig } = useInstallationConfiguration();
const enableDedicatedOnboardingFlow = !!installationConfig?.isDedicatedInstallation;
const params = useQueryParams();

const { needsSetup } = useNeedsSetup();
Expand Down
29 changes: 24 additions & 5 deletions components/dashboard/src/login/SSOLoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { useOnBlurError } from "../hooks/use-onblur-error";
import { openOIDCStartWindow } from "../provider-utils";
import { useFeatureFlag } from "../data/featureflag-query";
import { useLocation } from "react-router";
import { useOnboardingState } from "../dedicated-setup/use-needs-setup";
import { getOrgSlugFromQuery } from "../data/organizations/orgs-query";
import { storageAvailable } from "../utils";

type Props = {
singleOrgMode?: boolean;
onSuccess: () => void;
};

Expand All @@ -27,11 +29,13 @@ function getOrgSlugFromPath(path: string) {
return pathSegments[2];
}

export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
export const SSOLoginForm: FC<Props> = ({ onSuccess }) => {
const location = useLocation();
const { data: onboardingState } = useOnboardingState();
const singleOrgMode = (onboardingState?.organizationCountTotal || 0) < 2;

const [orgSlug, setOrgSlug] = useState(
getOrgSlugFromPath(location.pathname) || window.localStorage.getItem("sso-org-slug") || "",
getOrgSlugFromPath(location.pathname) || getOrgSlugFromQuery(location.search) || readSSOOrgSlug() || "",
);
const [error, setError] = useState("");

Expand All @@ -40,7 +44,7 @@ export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
const openLoginWithSSO = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
window.localStorage.setItem("sso-org-slug", orgSlug.trim());
persistSSOOrgSlug(orgSlug.trim());

try {
await openOIDCStartWindow({
Expand Down Expand Up @@ -78,7 +82,7 @@ export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
<div className="mt-10 space-y-2 w-56">
{!singleOrgMode && (
<TextInputField
label="Organization Slug"
label="Organization"
placeholder="my-organization"
value={orgSlug}
onChange={setOrgSlug}
Expand All @@ -99,3 +103,18 @@ export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
</form>
);
};

function readSSOOrgSlug(): string | undefined {
const isLocalStorageAvailable = storageAvailable("localStorage");
if (isLocalStorageAvailable) {
return window.localStorage.getItem("sso-org-slug") || undefined;
}
return undefined;
}

function persistSSOOrgSlug(slug: string) {
const isLocalStorageAvailable = storageAvailable("localStorage");
if (isLocalStorageAvailable) {
window.localStorage.setItem("sso-org-slug", slug.trim());
}
}
11 changes: 7 additions & 4 deletions components/dashboard/src/menu/OrganizationSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import { useCurrentUser } from "../user-context";
import { useCurrentOrg, useOrganizations } from "../data/organizations/orgs-query";
import { useLocation } from "react-router";
import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";
import { useFeatureFlag } from "../data/featureflag-query";
import { useIsOwner, useListOrganizationMembers, useHasRolePermission } from "../data/organizations/members-query";
import { isOrganizationOwned } from "@gitpod/public-api-common/lib/user-utils";
import { isAllowedToCreateOrganization } from "@gitpod/public-api-common/lib/user-utils";
import { OrganizationRole } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";
import { useFeatureFlag } from "../data/featureflag-query";

export default function OrganizationSelector() {
const user = useCurrentUser();
Expand All @@ -25,10 +26,12 @@ export default function OrganizationSelector() {
const hasMemberPermission = useHasRolePermission(OrganizationRole.MEMBER);
const { data: billingMode } = useOrgBillingMode();
const getOrgURL = useGetOrgURL();
const isDedicated = useFeatureFlag("enableDedicatedOnboardingFlow");
const { data: installationConfig } = useInstallationConfiguration();
const isDedicated = !!installationConfig?.isDedicatedInstallation;
const isMultiOrgEnabled = useFeatureFlag("enable_multi_org");

// we should have an API to ask for permissions, until then we duplicate the logic here
const canCreateOrgs = user && !isOrganizationOwned(user) && !isDedicated;
const canCreateOrgs = user && isAllowedToCreateOrganization(user, isDedicated, isMultiOrgEnabled);

const userFullName = user?.name || "...";

Expand Down
12 changes: 12 additions & 0 deletions components/dashboard/src/service/json-rpc-installation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
GetInstallationWorkspaceDefaultImageResponse,
GetOnboardingStateRequest,
GetOnboardingStateResponse,
GetInstallationConfigurationRequest,
GetInstallationConfigurationResponse,
} from "@gitpod/public-api/lib/gitpod/v1/installation_pb";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { getGitpodService } from "./service";
Expand Down Expand Up @@ -130,4 +132,14 @@ export class JsonRpcInstallationClient implements PromiseClient<typeof Installat
onboardingState: converter.toOnboardingState(info),
});
}

async getInstallationConfiguration(
request: Partial<GetInstallationConfigurationRequest>,
_options?: CallOptions | undefined,
): Promise<GetInstallationConfigurationResponse> {
const config = await getGitpodService().server.getConfiguration();
return new GetInstallationConfigurationResponse({
configuration: converter.toInstallationConfiguration(config),
});
}
}
2 changes: 1 addition & 1 deletion components/gitpod-db/BUILD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ packages:
# Note: In CI there is a DB running as sidecar; in workspaces we're starting it once.
# Re-use of the instance because of the init scripts (cmp. next step).
# (gpl): It would be nice to use bitnami/mysql here as we do in previews. However the container does not start in Gitpod workspaces due to some docker/kernel/namespace issue.
- ["sh", "-c", "mysqladmin ping --wait=${DB_RETRIES:-1} -h $DB_HOST --port $DB_PORT -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent || (docker run --name test-mysql -d -e MYSQL_ROOT_PASSWORD=$DB_PASSWORD -e MYSQL_TCP_PORT=$DB_PORT -p $DB_PORT:$DB_PORT mysql:8.0.33 --default-authentication-plugin=mysql_native_password; while ! mysqladmin ping -h \"$DB_HOST\" -P \"$DB_PORT\" -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent; do echo \"waiting for DB...\"; sleep 2; done)"]
- ["sh", "-c", "mysqladmin ping --wait=${DB_RETRIES:-1} -h $DB_HOST --port $DB_PORT -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent || (docker container rm test-mysql; docker run --name test-mysql -d -e MYSQL_ROOT_PASSWORD=$DB_PASSWORD -e MYSQL_TCP_PORT=$DB_PORT -p $DB_PORT:$DB_PORT mysql:8.0.33 --default-authentication-plugin=mysql_native_password; while ! mysqladmin ping -h \"$DB_HOST\" -P \"$DB_PORT\" -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent; do echo \"waiting for DB...\"; sleep 2; done)"]
# Apply the DB initialization scripts (re-creates the "gitpod" DB if already there)
- ["mkdir", "-p", "init-scripts"]
- ["sh", "-c", "find . -name \"*.sql\" | sort | xargs -I file cp file init-scripts"]
Expand Down
2 changes: 1 addition & 1 deletion components/gitpod-db/src/team-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const TeamDB = Symbol("TeamDB");
export interface TeamDB extends TransactionalDB<TeamDB> {
findTeams(
offset: number,
limit: number,
limit: number | undefined,
orderBy: keyof Team,
orderDir: "ASC" | "DESC",
searchTerm?: string,
Expand Down
8 changes: 6 additions & 2 deletions components/gitpod-db/src/typeorm/team-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {

public async findTeams(
offset: number,
limit: number,
limit: number | undefined,
orderBy: keyof Team,
orderDir: "DESC" | "ASC",
searchTerm?: string,
Expand All @@ -70,7 +70,11 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
searchTerm: `%${searchTerm}%`,
});
}
queryBuilder = queryBuilder.andWhere("markedDeleted = 0").skip(offset).take(limit).orderBy(orderBy, orderDir);
queryBuilder = queryBuilder.andWhere("markedDeleted = 0").skip(offset);
if (limit) {
queryBuilder = queryBuilder.take(limit);
}
queryBuilder = queryBuilder.orderBy(orderBy, orderDir);

const [rows, total] = await queryBuilder.getManyAndCount();
return { total, rows };
Expand Down
3 changes: 1 addition & 2 deletions components/gitpod-protocol/go/gitpod-service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1886,8 +1886,7 @@ type VSCodeConfig struct {

// Configuration is the Configuration message type
type Configuration struct {
DaysBeforeGarbageCollection float64 `json:"daysBeforeGarbageCollection,omitempty"`
GarbageCollectionStartDate float64 `json:"garbageCollectionStartDate,omitempty"`
IsDedicatedInstallation bool `json:"isDedicatedInstallation,omitempty"`
}

// EnvVar is the EnvVar message type
Expand Down
5 changes: 2 additions & 3 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,11 +489,10 @@ export namespace GitpodServer {
* Whether this Gitpod instance is already configured with SSO.
*/
readonly isCompleted: boolean;

/**
* Whether this Gitpod instance has at least one org.
* Total number of organizations.
*/
readonly hasAnyOrg: boolean;
readonly organizationCountTotal: number;
}
}

Expand Down
4 changes: 1 addition & 3 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1456,9 +1456,7 @@ export namespace AuthProviderEntry {
}

export interface Configuration {
readonly daysBeforeGarbageCollection: number;
readonly garbageCollectionStartDate: number;
readonly isSingleOrgInstallation: boolean;
readonly isDedicatedInstallation: boolean;
}

export interface StripeConfig {
Expand Down
15 changes: 15 additions & 0 deletions components/public-api/gitpod/v1/installation.proto
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ service InstallationService {

// GetOnboardingState returns the onboarding state of the installation.
rpc GetOnboardingState(GetOnboardingStateRequest) returns (GetOnboardingStateResponse) {}

// GetInstallationConfiguration returns configuration of the installation.
rpc GetInstallationConfiguration(GetInstallationConfigurationRequest) returns (GetInstallationConfigurationResponse) {}
}

message GetOnboardingStateRequest {}
Expand All @@ -39,7 +42,11 @@ message GetOnboardingStateResponse {
}

message OnboardingState {
// Whether at least one organization has completed the onboarding
bool completed = 1;

// The total number of organizations
int32 organization_count_total = 2;
}

message GetInstallationWorkspaceDefaultImageRequest {}
Expand Down Expand Up @@ -144,3 +151,11 @@ message BlockedEmailDomain {

bool negative = 3;
}

message GetInstallationConfigurationRequest {}
message GetInstallationConfigurationResponse {
InstallationConfiguration configuration = 1;
}
message InstallationConfiguration {
bool is_dedicated_installation = 1;
}
Loading

0 comments on commit 7f43d48

Please sign in to comment.