feat: implement 'is_default' org field (#12142)

The first organization created is now marked as "default". This is
to allow "single org" behavior as we move to a multi org codebase.

It is intentional that the user cannot change the default org at this
stage. Only 1 default org can exist, and it is always the first org.

Closes: https://github.com/coder/coder/issues/11961
This commit is contained in:
Steven Masley 2024-02-15 11:01:16 -06:00 committed by GitHub
parent a67362fdb1
commit 2bf2f88b09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 101 additions and 16 deletions

4
coderd/apidoc/docs.go generated
View File

@ -10342,6 +10342,7 @@ const docTemplate = `{
"required": [
"created_at",
"id",
"is_default",
"name",
"updated_at"
],
@ -10354,6 +10355,9 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"is_default": {
"type": "boolean"
},
"name": {
"type": "string"
},

View File

@ -9296,7 +9296,7 @@
},
"codersdk.Organization": {
"type": "object",
"required": ["created_at", "id", "name", "updated_at"],
"required": ["created_at", "id", "is_default", "name", "updated_at"],
"properties": {
"created_at": {
"type": "string",
@ -9306,6 +9306,9 @@
"type": "string",
"format": "uuid"
},
"is_default": {
"type": "boolean"
},
"name": {
"type": "string"
},

View File

@ -5285,6 +5285,7 @@ func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertO
Name: arg.Name,
CreatedAt: arg.CreatedAt,
UpdatedAt: arg.UpdatedAt,
IsDefault: len(q.organizations) == 0,
}
q.organizations = append(q.organizations, organization)
return organization, nil

View File

@ -502,7 +502,8 @@ CREATE TABLE organizations (
name text NOT NULL,
description text NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
updated_at timestamp with time zone NOT NULL,
is_default boolean DEFAULT false NOT NULL
);
CREATE TABLE parameter_schemas (
@ -1506,6 +1507,8 @@ CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
CREATE INDEX provisioner_job_logs_id_job_id_idx ON provisioner_job_logs USING btree (job_id, id);
CREATE INDEX provisioner_jobs_started_at_idx ON provisioner_jobs USING btree (started_at) WHERE (started_at IS NULL);

View File

@ -0,0 +1,2 @@
DROP INDEX organizations_single_default_org;
ALTER TABLE organizations DROP COLUMN is_default;

View File

@ -0,0 +1,16 @@
-- This migration is intended to maintain the existing behavior of single org
-- deployments, while allowing for multi-org deployments. By default, this organization
-- will be used when no organization is specified.
ALTER TABLE organizations ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE;
-- Only 1 org should ever be set to is_default.
create unique index organizations_single_default_org on organizations (is_default)
where is_default = true;
UPDATE
organizations
SET
is_default = true
WHERE
-- The first organization created will be the default.
id = (SELECT id FROM organizations ORDER BY organizations.created_at ASC LIMIT 1 );

View File

@ -1823,6 +1823,7 @@ type Organization struct {
Description string `db:"description" json:"description"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
IsDefault bool `db:"is_default" json:"is_default"`
}
type OrganizationMember struct {

View File

@ -494,6 +494,34 @@ func TestUserChangeLoginType(t *testing.T) {
require.Equal(t, bobExpPass, bob.HashedPassword, "hashed password should not change")
}
func TestDefaultOrg(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
ctx := context.Background()
// Should start with 0 orgs
all, err := db.GetOrganizations(ctx)
require.NoError(t, err)
require.Len(t, all, 0)
org, err := db.InsertOrganization(ctx, database.InsertOrganizationParams{
ID: uuid.New(),
Name: "default",
Description: "",
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
require.True(t, org.IsDefault, "first org should always be default")
}
type tvArgs struct {
Status database.ProvisionerJobStatus
// CreateWorkspace is true if we should create a workspace for the template version

View File

@ -3144,7 +3144,7 @@ func (q *sqlQuerier) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRole
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT
id, name, description, created_at, updated_at
id, name, description, created_at, updated_at, is_default
FROM
organizations
WHERE
@ -3160,13 +3160,14 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
)
return i, err
}
const getOrganizationByName = `-- name: GetOrganizationByName :one
SELECT
id, name, description, created_at, updated_at
id, name, description, created_at, updated_at, is_default
FROM
organizations
WHERE
@ -3184,13 +3185,14 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, name string) (Or
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
)
return i, err
}
const getOrganizations = `-- name: GetOrganizations :many
SELECT
id, name, description, created_at, updated_at
id, name, description, created_at, updated_at, is_default
FROM
organizations
`
@ -3210,6 +3212,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context) ([]Organization, erro
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
); err != nil {
return nil, err
}
@ -3226,7 +3229,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context) ([]Organization, erro
const getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many
SELECT
id, name, description, created_at, updated_at
id, name, description, created_at, updated_at, is_default
FROM
organizations
WHERE
@ -3255,6 +3258,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
); err != nil {
return nil, err
}
@ -3271,9 +3275,10 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U
const insertOrganization = `-- name: InsertOrganization :one
INSERT INTO
organizations (id, "name", description, created_at, updated_at)
organizations (id, "name", description, created_at, updated_at, is_default)
VALUES
($1, $2, $3, $4, $5) RETURNING id, name, description, created_at, updated_at
-- If no organizations exist, and this is the first, make it the default.
($1, $2, $3, $4, $5, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default
`
type InsertOrganizationParams struct {
@ -3299,6 +3304,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
)
return i, err
}

View File

@ -39,6 +39,7 @@ WHERE
-- name: InsertOrganization :one
INSERT INTO
organizations (id, "name", description, created_at, updated_at)
organizations (id, "name", description, created_at, updated_at, is_default)
VALUES
($1, $2, $3, $4, $5) RETURNING *;
-- If no organizations exist, and this is the first, make it the default.
($1, $2, $3, $4, $5, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *;

View File

@ -74,6 +74,7 @@ const (
UniqueIndexProvisionerDaemonsNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_name_owner_key ON provisioner_daemons USING btree (name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false);
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false);
UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false);
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);

View File

@ -118,5 +118,6 @@ func convertOrganization(organization database.Organization) codersdk.Organizati
Name: organization.Name,
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
IsDefault: organization.IsDefault,
}
}

View File

@ -24,6 +24,14 @@ func TestOrganizationsByUser(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, orgs)
require.Len(t, orgs, 1)
require.True(t, orgs[0].IsDefault, "first org is always default")
// Make an extra org, and it should not be defaulted.
notDefault, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "another",
})
require.NoError(t, err)
require.False(t, notDefault.IsDefault, "only 1 default org allowed")
}
func TestOrganizationByUserAndName(t *testing.T) {

View File

@ -30,6 +30,7 @@ type Organization struct {
Name string `json:"name" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"`
IsDefault bool `json:"is_default" validate:"required"`
}
type OrganizationMember struct {

View File

@ -123,6 +123,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations \
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_default": true,
"name": "string",
"updated_at": "2019-08-24T14:15:22Z"
}
@ -163,6 +164,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization} \
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_default": true,
"name": "string",
"updated_at": "2019-08-24T14:15:22Z"
}

14
docs/api/schemas.md generated
View File

@ -3811,6 +3811,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_default": true,
"name": "string",
"updated_at": "2019-08-24T14:15:22Z"
}
@ -3818,12 +3819,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------ | ------ | -------- | ------------ | ----------- |
| `created_at` | string | true | | |
| `id` | string | true | | |
| `name` | string | true | | |
| `updated_at` | string | true | | |
| Name | Type | Required | Restrictions | Description |
| ------------ | ------- | -------- | ------------ | ----------- |
| `created_at` | string | true | | |
| `id` | string | true | | |
| `is_default` | boolean | true | | |
| `name` | string | true | | |
| `updated_at` | string | true | | |
## codersdk.OrganizationMember

3
docs/api/users.md generated
View File

@ -993,6 +993,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations \
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_default": true,
"name": "string",
"updated_at": "2019-08-24T14:15:22Z"
}
@ -1014,6 +1015,7 @@ Status Code **200**
| `[array item]` | array | false | | |
| `» created_at` | string(date-time) | true | | |
| `» id` | string(uuid) | true | | |
| `» is_default` | boolean | true | | |
| `» name` | string | true | | |
| `» updated_at` | string(date-time) | true | | |
@ -1047,6 +1049,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations/{organiza
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_default": true,
"name": "string",
"updated_at": "2019-08-24T14:15:22Z"
}

View File

@ -768,6 +768,7 @@ export interface Organization {
readonly name: string;
readonly created_at: string;
readonly updated_at: string;
readonly is_default: boolean;
}
// From codersdk/organizations.go

View File

@ -16,6 +16,7 @@ export const MockOrganization: TypesGen.Organization = {
name: "Test Organization",
created_at: "",
updated_at: "",
is_default: true,
};
export const MockTemplateDAUResponse: TypesGen.DAUsResponse = {