From 033021d203b86580cec6a6730e7d6fa153c09ec1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 14 Feb 2024 10:40:13 -0600 Subject: [PATCH 1/6] feat: implement 'is_default' org field 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. --- coderd/database/dbmem/dbmem.go | 1 + coderd/database/dump.sql | 5 +++- .../000192_default_organization.down.sql | 2 ++ .../000192_default_organization.up.sql | 16 +++++++++++ coderd/database/models.go | 1 + coderd/database/querier_test.go | 28 +++++++++++++++++++ coderd/database/queries.sql.go | 17 +++++++---- coderd/database/queries/organizations.sql | 5 ++-- coderd/database/unique_constraint.go | 1 + coderd/organizations.go | 1 + coderd/organizations_test.go | 8 ++++++ codersdk/organizations.go | 1 + 12 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 coderd/database/migrations/000192_default_organization.down.sql create mode 100644 coderd/database/migrations/000192_default_organization.up.sql diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8901d13d3d4df..6f7dba0999b92 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -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 diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f2b410da5a198..c05a259faabde 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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); diff --git a/coderd/database/migrations/000192_default_organization.down.sql b/coderd/database/migrations/000192_default_organization.down.sql new file mode 100644 index 0000000000000..e53bb4c6d688b --- /dev/null +++ b/coderd/database/migrations/000192_default_organization.down.sql @@ -0,0 +1,2 @@ +DROP INDEX organizations_single_default_org; +ALTER TABLE organizations DROP COLUMN is_default; diff --git a/coderd/database/migrations/000192_default_organization.up.sql b/coderd/database/migrations/000192_default_organization.up.sql new file mode 100644 index 0000000000000..a2bd1983abdaf --- /dev/null +++ b/coderd/database/migrations/000192_default_organization.up.sql @@ -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 ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 50c4d0b5c02d3..7156d772a3c4d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 4fd0579aff242..0f7b0cd95d7fb 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 97b33038ea468..fb5497327e40f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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,9 @@ 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 + ($1, $2, $3, $4, $5, (SELECT count(*) FROM organizations) = 0) RETURNING id, name, description, created_at, updated_at, is_default ` type InsertOrganizationParams struct { @@ -3299,6 +3303,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat &i.Description, &i.CreatedAt, &i.UpdatedAt, + &i.IsDefault, ) return i, err } diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index 87c403049efd2..d5983fb0387fb 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -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 count(*) FROM organizations) = 0) RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 747aa3e07bb2f..fa1efffb8137c 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -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); diff --git a/coderd/organizations.go b/coderd/organizations.go index ae24edea01597..d50c0a4e250cc 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -118,5 +118,6 @@ func convertOrganization(organization database.Organization) codersdk.Organizati Name: organization.Name, CreatedAt: organization.CreatedAt, UpdatedAt: organization.UpdatedAt, + IsDefault: organization.IsDefault, } } diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index c8cde696e22a2..6fc98d7c9add9 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -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) { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 6a629160207e8..8bf1ecc96811d 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -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 { From d310e0a987056bddad9a07015e2eaf0638fac0b0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 14 Feb 2024 11:00:42 -0600 Subject: [PATCH 2/6] make gen --- coderd/apidoc/docs.go | 4 ++++ coderd/apidoc/swagger.json | 5 ++++- coderd/database/queries.sql.go | 1 + docs/api/organizations.md | 2 ++ docs/api/schemas.md | 14 ++++++++------ docs/api/users.md | 3 +++ 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 412d11f2f80a7..ba528c74d6b46 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7b8457ae42042..d09381cf743d1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fb5497327e40f..9159bcf7db075 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3277,6 +3277,7 @@ const insertOrganization = `-- name: InsertOrganization :one INSERT INTO organizations (id, "name", description, created_at, updated_at, is_default) VALUES + -- If no organizations exist, and this is the first, make it the default. ($1, $2, $3, $4, $5, (SELECT count(*) FROM organizations) = 0) RETURNING id, name, description, created_at, updated_at, is_default ` diff --git a/docs/api/organizations.md b/docs/api/organizations.md index 011d3cac5eb2e..478c8aba56648 100644 --- a/docs/api/organizations.md +++ b/docs/api/organizations.md @@ -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" } diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 2ea43f292b546..be5724e361159 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -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 diff --git a/docs/api/users.md b/docs/api/users.md index e656ed6ac27a7..a057376b38f16 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -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" } From 6319ebc39830472665a4e46b5832aa45264878d2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 14 Feb 2024 11:29:45 -0600 Subject: [PATCH 3/6] make gen --- site/src/api/typesGenerated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ac605e193f1f3..9c72b8c1c927a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 From 161be2794b0891ebf67f2bceeaaa7bd3c02bfcf2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 14 Feb 2024 11:39:23 -0600 Subject: [PATCH 4/6] fix js lint --- site/src/testHelpers/entities.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ce4f9d836aa0e..aa91756f725df 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -16,6 +16,7 @@ export const MockOrganization: TypesGen.Organization = { name: "Test Organization", created_at: "", updated_at: "", + is_default: true, }; export const MockTemplateDAUResponse: TypesGen.DAUsResponse = { From 5bb0ffcbc543fc71e48909446f015a691c5faa7c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 15 Feb 2024 10:26:51 -0600 Subject: [PATCH 5/6] Slightly more optimized query --- coderd/database/queries.sql.go | 2 +- coderd/database/queries/organizations.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9159bcf7db075..28d0a34f5ea80 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3278,7 +3278,7 @@ INSERT INTO organizations (id, "name", description, created_at, updated_at, is_default) VALUES -- If no organizations exist, and this is the first, make it the default. - ($1, $2, $3, $4, $5, (SELECT count(*) FROM organizations) = 0) RETURNING id, name, description, created_at, updated_at, is_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 { diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index d5983fb0387fb..05185e9c90dec 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -42,4 +42,4 @@ INSERT INTO organizations (id, "name", description, created_at, updated_at, is_default) VALUES -- If no organizations exist, and this is the first, make it the default. - ($1, $2, $3, $4, $5, (SELECT count(*) FROM organizations) = 0) RETURNING *; + ($1, $2, $3, $4, $5, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; From 379a1335159c0024bd390993ed13933455367634 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 15 Feb 2024 10:39:46 -0600 Subject: [PATCH 6/6] bump migration number --- ...organization.down.sql => 000193_default_organization.down.sql} | 0 ...ult_organization.up.sql => 000193_default_organization.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000192_default_organization.down.sql => 000193_default_organization.down.sql} (100%) rename coderd/database/migrations/{000192_default_organization.up.sql => 000193_default_organization.up.sql} (100%) diff --git a/coderd/database/migrations/000192_default_organization.down.sql b/coderd/database/migrations/000193_default_organization.down.sql similarity index 100% rename from coderd/database/migrations/000192_default_organization.down.sql rename to coderd/database/migrations/000193_default_organization.down.sql diff --git a/coderd/database/migrations/000192_default_organization.up.sql b/coderd/database/migrations/000193_default_organization.up.sql similarity index 100% rename from coderd/database/migrations/000192_default_organization.up.sql rename to coderd/database/migrations/000193_default_organization.up.sql