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/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/000193_default_organization.down.sql b/coderd/database/migrations/000193_default_organization.down.sql new file mode 100644 index 0000000000000..e53bb4c6d688b --- /dev/null +++ b/coderd/database/migrations/000193_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/000193_default_organization.up.sql b/coderd/database/migrations/000193_default_organization.up.sql new file mode 100644 index 0000000000000..a2bd1983abdaf --- /dev/null +++ b/coderd/database/migrations/000193_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..28d0a34f5ea80 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,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 } diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index 87c403049efd2..05185e9c90dec 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 TRUE FROM organizations LIMIT 1) IS NULL) 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 { 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" } 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 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 = {