From 98f00cdbf922644042c90e43728f52e122ba6d47 Mon Sep 17 00:00:00 2001
From: Louis Garman <75728+leg100@users.noreply.github.com>
Date: Thu, 4 May 2023 20:05:40 +0100
Subject: [PATCH] Workspace Tags (#413)

---
 .gitignore                                    |   2 -
 Makefile                                      |  45 +-
 api/api.go                                    |   4 +-
 api/marshaler.go                              |   4 +-
 api/tag.go                                    | 171 ++++++
 api/tag_marshaler.go                          |  22 +
 api/types/tag.go                              |  26 +
 api/types/workspace.go                        |   5 +
 api/workspace.go                              |   3 +
 api/workspace_marshaler.go                    |   1 +
 configversion/db.go                           |   2 +-
 daemon/daemon.go                              |  20 +-
 docker-compose.yml                            |   2 +
 hack/go-tfe-tests.bash                        |   3 +-
 http/html/paths/funcmap.go                    |   2 +
 http/html/paths/gen.go                        |   6 +
 http/html/paths/workspace_paths.go            |   8 +
 http/html/static/css/forms.css                |  29 ++
 http/html/static/css/main.css                 |  75 ++-
 .../templates/content/workspace_edit.tmpl     |  34 +-
 .../templates/content/workspace_get.tmpl      |  58 ++-
 .../templates/content/workspace_list.tmpl     |  12 +
 integration/tag_e2e_test.go                   | 109 ++++
 integration/tag_service_test.go               | 150 ++++++
 integration/test_helpers.go                   |   6 +-
 integration/workspace_test.go                 |  56 +-
 organization/db.go                            |   2 +-
 otf.go                                        |  15 +
 policy/policy.go                              |   3 -
 policy/service.go                             |  92 ----
 rbac/action.go                                |   7 +
 rbac/action_string.go                         |  62 ++-
 rbac/role.go                                  |   4 +
 repo/db.go                                    |   5 +-
 repo/service.go                               |   2 +-
 run/db.go                                     |  10 +-
 scheduler/scheduler.go                        |   2 +-
 ...0230427144312_add_table_workspace_tags.sql |  18 +
 sql/pggen/agent_token.sql.go                  | 178 ++++++-
 sql/pggen/configuration_version.sql.go        |  12 +-
 sql/pggen/organization.sql.go                 |  14 +-
 sql/pggen/repo_connections.sql.go             |  12 +-
 sql/pggen/run.sql.go                          |  12 +-
 sql/pggen/state_version.sql.go                |  12 +-
 sql/pggen/tags.sql.go                         | 493 ++++++++++++++++++
 sql/pggen/workspace.sql.go                    | 152 ++++--
 sql/pggen/workspace_permission.sql.go         |  97 ++--
 sql/queries/organization.sql                  |   2 +-
 sql/queries/tags.sql                          | 117 +++++
 sql/queries/workspace.sql                     |  77 ++-
 sql/queries/workspace_permission.sql          |  23 +-
 state/db.go                                   |   2 +-
 variable/service.go                           |   9 +-
 variable/web.go                               |   2 -
 workspace/authorizer.go                       |  32 ++
 workspace/db.go                               |  27 +-
 workspace/lock_service.go                     |   4 +-
 policy/db.go => workspace/permissions_db.go   |  41 +-
 workspace/permissions_service.go              |  53 ++
 workspace/service.go                          |  47 +-
 workspace/tag.go                              |  58 +++
 workspace/tag_db.go                           | 171 ++++++
 workspace/tag_service.go                      | 262 ++++++++++
 workspace/tags_web.go                         |  57 ++
 workspace/test_helpers.go                     |   7 +-
 workspace/web.go                              |  63 ++-
 workspace/web_test.go                         |   6 +-
 workspace/workspace.go                        |   9 +-
 68 files changed, 2678 insertions(+), 450 deletions(-)
 create mode 100644 api/tag.go
 create mode 100644 api/tag_marshaler.go
 create mode 100644 api/types/tag.go
 create mode 100644 integration/tag_e2e_test.go
 create mode 100644 integration/tag_service_test.go
 delete mode 100644 policy/policy.go
 delete mode 100644 policy/service.go
 create mode 100644 sql/migrations/20230427144312_add_table_workspace_tags.sql
 create mode 100644 sql/pggen/tags.sql.go
 create mode 100644 sql/queries/tags.sql
 create mode 100644 workspace/authorizer.go
 rename policy/db.go => workspace/permissions_db.go (50%)
 create mode 100644 workspace/permissions_service.go
 create mode 100644 workspace/tag.go
 create mode 100644 workspace/tag_db.go
 create mode 100644 workspace/tag_service.go
 create mode 100644 workspace/tags_web.go

diff --git a/.gitignore b/.gitignore
index 1d233f7d2..abeee68f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -97,8 +97,6 @@ anaconda-mode/
 Session.vim
 # temporary
 .netrwhist
-# auto-generated tag files
-tags
 ### VisualStudioCode ###
 .vscode/*
 .history
diff --git a/Makefile b/Makefile
index 8feee4bd8..123c0e87d 100644
--- a/Makefile
+++ b/Makefile
@@ -20,12 +20,22 @@ endif
 
 # go-tfe-tests runs API tests - before it does that, it builds the otfd docker
 # image and starts up otfd and postgres using docker compose, and then the
-# tests are run against it
+# tests are run against it.
+#
+# NOTE: two batches of tests are run:
+# (1) using the forked repo
+# (2) using the upstream repo, for tests against new features, like workspace tags
 .PHONY: go-tfe-tests
-go-tfe-tests: image
-	docker compose up -d
+go-tfe-tests: image compose-up go-tfe-tests-forked go-tfe-tests-upstream
+
+.PHONY: go-tfe-tests-forked
+go-tfe-tests-forked:
 	./hack/go-tfe-tests.bash
 
+.PHONY: go-tfe-tests-upstream
+go-tfe-tests-upstream:
+	GO_TFE_REPO=github.com/hashicorp/go-tfe@latest ./hack/go-tfe-tests.bash 'Test(OrganizationTags|Workspaces_(Add|Remove)Tags)|TestWorkspacesList/when_searching_using_a_tag'
+
 .PHONY: test
 test:
 	go test ./...
@@ -50,25 +60,25 @@ install-latest-release:
 	unzip -o -d $(GOBIN) $$ZIP_FILE otfd ;\
 	}
 
-# Run postgresql in a container
+# Run docker compose stack
+.PHONY: compose-up
+compose-up: image
+	docker compose up -d
+
+# Remove docker compose stack
+.PHONY: compose-rm
+compose-rm:
+	docker compose rm -sf
+
+# Run postgresql via docker compose
 .PHONY: postgres
 postgres:
-	docker compose -f docker-compose.yml up -d postgres
+	docker compose up -d postgres
 
-# Stop and remove postgres container
-.PHONY: postgres-rm
-postgres-rm:
-	docker compose -f docker-compose.yml rm -sf
-
-# Run squid caching proxy in a container
+# Run squid via docker compose
 .PHONY: squid
 squid:
-	docker run --rm --name squid -t -d -p 3128:3128 -v $(PWD)/integration/fixtures:/etc/squid/certs leg100/squid:0.2
-
-# Stop squid container
-.PHONY: squid-stop
-squid-stop:
-	docker stop --signal INT squid
+	docker compose up -d squid
 
 # Run staticcheck metalinter recursively against code
 .PHONY: lint
@@ -115,6 +125,7 @@ sql: install-pggen
 		--output-dir sql/pggen \
 		--go-type 'text=github.com/jackc/pgtype.Text' \
 		--go-type 'int4=int' \
+		--go-type 'int8=int' \
 		--go-type 'bool=bool' \
 		--go-type 'bytea=[]byte' \
 		--acronym url \
diff --git a/api/api.go b/api/api.go
index ffe393223..5e2043bca 100644
--- a/api/api.go
+++ b/api/api.go
@@ -9,7 +9,6 @@ import (
 	"github.com/leg100/otf/logr"
 	"github.com/leg100/otf/organization"
 	"github.com/leg100/otf/orgcreator"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/run"
 	"github.com/leg100/otf/state"
 	"github.com/leg100/otf/tokens"
@@ -48,7 +47,6 @@ type (
 		auth.AuthService
 		tokens.TokensService
 		variable.VariableService
-		policy.PolicyService
 
 		*surl.Signer
 
@@ -73,7 +71,6 @@ func New(opts Options) *api {
 			WorkspaceService:    opts.WorkspaceService,
 			RunService:          opts.RunService,
 			StateService:        opts.StateService,
-			PolicyService:       opts.PolicyService,
 			runLogsURLGenerator: &runLogsURLGenerator{opts.Signer},
 		},
 		maxConfigSize: opts.MaxConfigSize,
@@ -85,6 +82,7 @@ func (a *api) AddHandlers(r *mux.Router) {
 	a.addRunHandlers(r)
 	a.addWorkspaceHandlers(r)
 	a.addStateHandlers(r)
+	a.addTagHandlers(r)
 	a.addConfigHandlers(r)
 	a.addUserHandlers(r)
 	a.addTeamHandlers(r)
diff --git a/api/marshaler.go b/api/marshaler.go
index 1db31e1c7..dea39bd99 100644
--- a/api/marshaler.go
+++ b/api/marshaler.go
@@ -10,7 +10,6 @@ import (
 	"github.com/leg100/otf/auth"
 	"github.com/leg100/otf/configversion"
 	"github.com/leg100/otf/organization"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/run"
 	"github.com/leg100/otf/state"
 	"github.com/leg100/otf/variable"
@@ -33,7 +32,6 @@ type (
 		organization.OrganizationService
 		state.StateService
 		workspace.WorkspaceService
-		policy.PolicyService
 
 		*runLogsURLGenerator
 	}
@@ -84,6 +82,8 @@ func (m *jsonapiMarshaler) writeResponse(w http.ResponseWriter, r *http.Request,
 		payload = m.toUser(v)
 	case *auth.Team:
 		payload = m.toTeam(v)
+	case *workspace.TagList:
+		payload, marshalOpts = m.toTags(v)
 	default:
 		Error(w, fmt.Errorf("cannot marshal unknown type: %T", v))
 		return
diff --git a/api/tag.go b/api/tag.go
new file mode 100644
index 000000000..cd1cb7a03
--- /dev/null
+++ b/api/tag.go
@@ -0,0 +1,171 @@
+package api
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/gorilla/mux"
+	"github.com/leg100/otf/api/types"
+	otfhttp "github.com/leg100/otf/http"
+	"github.com/leg100/otf/http/decode"
+	"github.com/leg100/otf/workspace"
+)
+
+const (
+	addTags tagOperation = iota
+	removeTags
+)
+
+type tagOperation int
+
+func (a *api) addTagHandlers(r *mux.Router) {
+	r = otfhttp.APIRouter(r)
+
+	r.HandleFunc("/workspaces/{workspace_id}/relationships/tags", a.addTags).Methods("POST")
+	r.HandleFunc("/workspaces/{workspace_id}/relationships/tags", a.removeTags).Methods("DELETE")
+	r.HandleFunc("/workspaces/{workspace_id}/relationships/tags", a.getTags).Methods("GET")
+
+	r.HandleFunc("/organizations/{organization_name}/tags", a.listTags).Methods("GET")
+	r.HandleFunc("/organizations/{organization_name}/tags", a.deleteTags).Methods("DELETE")
+	r.HandleFunc("/tags/{tag_id}/relationships/workspaces", a.tagWorkspaces).Methods("POST")
+}
+
+func (a *api) listTags(w http.ResponseWriter, r *http.Request) {
+	org, err := decode.Param("organization_name", r)
+	if err != nil {
+		Error(w, err)
+		return
+	}
+	var params workspace.ListTagsOptions
+	if err := decode.All(&params, r); err != nil {
+		Error(w, err)
+		return
+	}
+
+	tags, err := a.ListTags(r.Context(), org, params)
+	if err != nil {
+		Error(w, err)
+		return
+	}
+
+	a.writeResponse(w, r, tags)
+}
+
+func (a *api) deleteTags(w http.ResponseWriter, r *http.Request) {
+	org, err := decode.Param("organization_name", r)
+	if err != nil {
+		Error(w, err)
+		return
+	}
+	var params []struct {
+		ID string `jsonapi:"primary,tags"`
+	}
+	if err := unmarshal(r.Body, &params); err != nil {
+		Error(w, err)
+		return
+	}
+	var tagIDs []string
+	for _, p := range params {
+		tagIDs = append(tagIDs, p.ID)
+	}
+
+	if err := a.DeleteTags(r.Context(), org, tagIDs); err != nil {
+		Error(w, err)
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (a *api) tagWorkspaces(w http.ResponseWriter, r *http.Request) {
+	tagID, err := decode.Param("tag_id", r)
+	if err != nil {
+		Error(w, err)
+		return
+	}
+	var params []*types.Workspace
+	if err := unmarshal(r.Body, &params); err != nil {
+		Error(w, err)
+		return
+	}
+	var workspaceIDs []string
+	for _, p := range params {
+		workspaceIDs = append(workspaceIDs, p.ID)
+	}
+
+	if err := a.TagWorkspaces(r.Context(), tagID, workspaceIDs); err != nil {
+		Error(w, err)
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (a *api) addTags(w http.ResponseWriter, r *http.Request) {
+	a.alterWorkspaceTags(w, r, addTags)
+}
+
+func (a *api) removeTags(w http.ResponseWriter, r *http.Request) {
+	a.alterWorkspaceTags(w, r, removeTags)
+}
+
+func (a *api) alterWorkspaceTags(w http.ResponseWriter, r *http.Request, op tagOperation) {
+	workspaceID, err := decode.Param("workspace_id", r)
+	if err != nil {
+		Error(w, err)
+		return
+	}
+	var params []*types.Tag
+	if err := unmarshal(r.Body, &params); err != nil {
+		Error(w, err)
+		return
+	}
+	// convert from json:api structs to tag specs
+	specs := toTagSpecs(params)
+
+	switch op {
+	case addTags:
+		err = a.AddTags(r.Context(), workspaceID, specs)
+	case removeTags:
+		err = a.RemoveTags(r.Context(), workspaceID, specs)
+	default:
+		err = errors.New("unknown tag operation")
+	}
+	if err != nil {
+		Error(w, err)
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (a *api) getTags(w http.ResponseWriter, r *http.Request) {
+	workspaceID, err := decode.Param("workspace_id", r)
+	if err != nil {
+		Error(w, err)
+		return
+	}
+	var params workspace.ListWorkspaceTagsOptions
+	if err := decode.All(&params, r); err != nil {
+		Error(w, err)
+		return
+	}
+
+	tags, err := a.ListWorkspaceTags(r.Context(), workspaceID, params)
+	if err != nil {
+		Error(w, err)
+		return
+	}
+
+	a.writeResponse(w, r, tags)
+}
+
+func toTagSpecs(from []*types.Tag) (to []workspace.TagSpec) {
+	for _, tag := range from {
+		to = append(to, workspace.TagSpec{
+			ID:   tag.ID,
+			Name: tag.Name,
+		})
+	}
+	return
+}
diff --git a/api/tag_marshaler.go b/api/tag_marshaler.go
new file mode 100644
index 000000000..451e78d62
--- /dev/null
+++ b/api/tag_marshaler.go
@@ -0,0 +1,22 @@
+package api
+
+import (
+	"github.com/DataDog/jsonapi"
+	"github.com/leg100/otf/api/types"
+	"github.com/leg100/otf/workspace"
+)
+
+func (m *jsonapiMarshaler) toTags(from *workspace.TagList) (to []*types.OrganizationTag, opts []jsonapi.MarshalOption) {
+	for _, ft := range from.Items {
+		to = append(to, &types.OrganizationTag{
+			ID:            ft.ID,
+			Name:          ft.Name,
+			InstanceCount: ft.InstanceCount,
+			Organization: &types.Organization{
+				Name: ft.Organization,
+			},
+		})
+	}
+	opts = []jsonapi.MarshalOption{toMarshalOption(from.Pagination)}
+	return
+}
diff --git a/api/types/tag.go b/api/types/tag.go
new file mode 100644
index 000000000..391245106
--- /dev/null
+++ b/api/types/tag.go
@@ -0,0 +1,26 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package types
+
+type (
+	// OrganizationTag represents a Terraform Enterprise Organization tag
+	OrganizationTag struct {
+		ID string `jsonapi:"primary,tags"`
+
+		// Optional:
+		Name string `jsonapi:"attribute" json:"name,omitempty"`
+
+		// Optional: Number of workspaces that have this tag
+		InstanceCount int `jsonapi:"attribute" json:"instance-count,omitempty"`
+
+		// The org this tag belongs to
+		Organization *Organization `jsonapi:"relationship" json:"organization"`
+	}
+
+	// Tag is owned by an organization and applied to workspaces. Used for grouping and search.
+	Tag struct {
+		ID   string `jsonapi:"primary,tags"`
+		Name string `jsonapi:"attr,name,omitempty"`
+	}
+)
diff --git a/api/types/workspace.go b/api/types/workspace.go
index 7ebcb2ed2..a7315c43f 100644
--- a/api/types/workspace.go
+++ b/api/types/workspace.go
@@ -45,6 +45,7 @@ type Workspace struct {
 	PolicyCheckFailures        int                   `jsonapi:"attribute" json:"policy-check-failures"`
 	RunFailures                int                   `jsonapi:"attribute" json:"run-failures"`
 	RunsCount                  int                   `jsonapi:"attribute" json:"workspace-kpis-runs-count"`
+	TagNames                   []string              `jsonapi:"attribute" json:"tag-names"`
 
 	// Relations
 	CurrentRun   *Run                  `jsonapi:"relationship" json:"current-run"`
@@ -184,6 +185,10 @@ type WorkspaceCreateOptions struct {
 	// root of your repository and is typically set to a subdirectory matching the
 	// environment when multiple environments exist within the same repository.
 	WorkingDirectory *string `jsonapi:"attribute" json:"working-directory,omitempty"`
+
+	// A list of tags to attach to the workspace. If the tag does not already
+	// exist, it is created and added to the workspace.
+	Tags []*Tag `jsonapi:"relationship" json:"tags,omitempty"`
 }
 
 // WorkspaceUpdateOptions represents the options for updating a workspace.
diff --git a/api/workspace.go b/api/workspace.go
index 214a71fc9..245d79466 100644
--- a/api/workspace.go
+++ b/api/workspace.go
@@ -47,6 +47,7 @@ func (a *api) createWorkspace(w http.ResponseWriter, r *http.Request) {
 		Error(w, err)
 		return
 	}
+
 	opts := workspace.CreateOptions{
 		AllowDestroyPlan:           params.AllowDestroyPlan,
 		AutoApply:                  params.AutoApply,
@@ -65,6 +66,8 @@ func (a *api) createWorkspace(w http.ResponseWriter, r *http.Request) {
 		TerraformVersion:           params.TerraformVersion,
 		TriggerPrefixes:            params.TriggerPrefixes,
 		WorkingDirectory:           params.WorkingDirectory,
+		// convert from json:api structs to tag specs
+		Tags: toTagSpecs(params.Tags),
 	}
 	if params.Operations != nil {
 		if params.ExecutionMode != nil {
diff --git a/api/workspace_marshaler.go b/api/workspace_marshaler.go
index b3d0ff7c4..389c671ac 100644
--- a/api/workspace_marshaler.go
+++ b/api/workspace_marshaler.go
@@ -61,6 +61,7 @@ func (m *jsonapiMarshaler) toWorkspace(from *workspace.Workspace, r *http.Reques
 		TerraformVersion:           from.TerraformVersion,
 		TriggerPrefixes:            from.TriggerPrefixes,
 		WorkingDirectory:           from.WorkingDirectory,
+		TagNames:                   from.Tags,
 		UpdatedAt:                  from.UpdatedAt,
 		Organization:               &types.Organization{Name: from.Organization},
 		Outputs:                    []*types.StateVersionOutput{},
diff --git a/configversion/db.go b/configversion/db.go
index 207cd8d03..2f0bc9940 100644
--- a/configversion/db.go
+++ b/configversion/db.go
@@ -96,7 +96,7 @@ func (db *pgdb) ListConfigurationVersions(ctx context.Context, workspaceID strin
 
 	return &ConfigurationVersionList{
 		Items:      items,
-		Pagination: otf.NewPagination(opts.ListOptions, *count),
+		Pagination: otf.NewPagination(opts.ListOptions, count),
 	}, nil
 }
 
diff --git a/daemon/daemon.go b/daemon/daemon.go
index d2753c797..c5e390efd 100644
--- a/daemon/daemon.go
+++ b/daemon/daemon.go
@@ -24,7 +24,6 @@ import (
 	"github.com/leg100/otf/module"
 	"github.com/leg100/otf/organization"
 	"github.com/leg100/otf/orgcreator"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/pubsub"
 	"github.com/leg100/otf/repo"
 	"github.com/leg100/otf/run"
@@ -62,7 +61,6 @@ type (
 		run.RunService
 		repo.RepoService
 		logs.LogsService
-		policy.PolicyService
 
 		Handlers []otf.Handlers
 	}
@@ -167,11 +165,6 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
 		VCSProviderService: vcsProviderService,
 	})
 
-	policyService := policy.NewService(policy.Options{
-		Logger: logger,
-		DB:     db,
-	})
-
 	workspaceService := workspace.NewService(workspace.Options{
 		Logger:              logger,
 		DB:                  db,
@@ -181,13 +174,11 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
 		TeamService:         authService,
 		OrganizationService: orgService,
 		VCSProviderService:  vcsProviderService,
-		WorkspaceAuthorizer: policyService,
-		PolicyService:       policyService,
 	})
 	configService := configversion.NewService(configversion.Options{
 		Logger:              logger,
 		DB:                  db,
-		WorkspaceAuthorizer: policyService,
+		WorkspaceAuthorizer: workspaceService,
 		Cache:               cache,
 		Signer:              signer,
 	})
@@ -195,7 +186,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
 		Logger:                      logger,
 		DB:                          db,
 		Renderer:                    renderer,
-		WorkspaceAuthorizer:         policyService,
+		WorkspaceAuthorizer:         workspaceService,
 		WorkspaceService:            workspaceService,
 		ConfigurationVersionService: configService,
 		VCSProviderService:          vcsProviderService,
@@ -222,7 +213,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
 	stateService := state.NewService(state.Options{
 		Logger:              logger,
 		DB:                  db,
-		WorkspaceAuthorizer: policyService,
+		WorkspaceAuthorizer: workspaceService,
 		WorkspaceService:    workspaceService,
 		Cache:               cache,
 	})
@@ -230,9 +221,8 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
 		Logger:              logger,
 		DB:                  db,
 		Renderer:            renderer,
-		WorkspaceAuthorizer: policyService,
+		WorkspaceAuthorizer: workspaceService,
 		WorkspaceService:    workspaceService,
-		PolicyService:       policyService,
 	})
 
 	agent, err := agent.NewAgent(
@@ -279,7 +269,6 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
 		AuthService:                 authService,
 		TokensService:               tokensService,
 		VariableService:             variableService,
-		PolicyService:               policyService,
 		Signer:                      signer,
 		MaxConfigSize:               cfg.MaxConfigSize,
 	})
@@ -321,7 +310,6 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
 		RunService:                  runService,
 		LogsService:                 logsService,
 		RepoService:                 repoService,
-		PolicyService:               policyService,
 		Broker:                      broker,
 		DB:                          db,
 		agent:                       agent,
diff --git a/docker-compose.yml b/docker-compose.yml
index eb04d04d9..e3f1b1037 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,6 +18,8 @@ services:
         condition: service_healthy
   squid:
     image: leg100/squid
+    ports:
+      - "3128:3128"
     healthcheck:
       test: ["CMD-SHELL", "nc -zw1 localhost 3128"]
       interval: 5s
diff --git a/hack/go-tfe-tests.bash b/hack/go-tfe-tests.bash
index e316e18a7..20a9ae5de 100755
--- a/hack/go-tfe-tests.bash
+++ b/hack/go-tfe-tests.bash
@@ -9,6 +9,7 @@
 
 set -e
 
+GO_TFE_REPO="${GO_TFE_REPO:-github.com/leg100/go-tfe@otf}"
 TESTS="${@:-Test(Variables|Workspaces(Create|List|Update|Delete|Lock|Unlock|ForceUnlock|Read\$|ReadByID)|Organizations(Create|List|Read|Update)|StateVersion|Runs|Plans|Applies(Read|Logs)|ConfigurationVersions)}"
 
 export TFE_ADDRESS="${TFE_ADDRESS:-https://localhost:8833}"
@@ -20,5 +21,5 @@ export TFE_TOKEN=${TFE_TOKEN:-site-token}
 export SKIP_PAID=1
 export SSL_CERT_FILE=$PWD/integration/fixtures/cert.pem
 
-cd $(go mod download -json github.com/leg100/go-tfe@otf | jq -r '.Dir')
+cd $(go mod download -json ${GO_TFE_REPO} | jq -r '.Dir')
 go test -v -run $TESTS -timeout 60s
diff --git a/http/html/paths/funcmap.go b/http/html/paths/funcmap.go
index d4a9b0043..15829a422 100644
--- a/http/html/paths/funcmap.go
+++ b/http/html/paths/funcmap.go
@@ -51,6 +51,8 @@ func init() {
 	funcmap["startRunWorkspacePath"] = StartRunWorkspace
 	funcmap["setupConnectionProviderWorkspacePath"] = SetupConnectionProviderWorkspace
 	funcmap["setupConnectionRepoWorkspacePath"] = SetupConnectionRepoWorkspace
+	funcmap["createTagWorkspacePath"] = CreateTagWorkspace
+	funcmap["deleteTagWorkspacePath"] = DeleteTagWorkspace
 
 	funcmap["runsPath"] = Runs
 	funcmap["createRunPath"] = CreateRun
diff --git a/http/html/paths/gen.go b/http/html/paths/gen.go
index 9cf7b73bf..bb4bfe1bd 100644
--- a/http/html/paths/gen.go
+++ b/http/html/paths/gen.go
@@ -170,6 +170,12 @@ var specs = []controllerSpec{
 					{
 						name: "setup-connection-repo",
 					},
+					{
+						name: "create-tag",
+					},
+					{
+						name: "delete-tag",
+					},
 				},
 				nested: []controllerSpec{
 					{
diff --git a/http/html/paths/workspace_paths.go b/http/html/paths/workspace_paths.go
index 5de4ff3a0..f56b52344 100644
--- a/http/html/paths/workspace_paths.go
+++ b/http/html/paths/workspace_paths.go
@@ -75,3 +75,11 @@ func SetupConnectionProviderWorkspace(workspace string) string {
 func SetupConnectionRepoWorkspace(workspace string) string {
 	return fmt.Sprintf("/app/workspaces/%s/setup-connection-repo", workspace)
 }
+
+func CreateTagWorkspace(workspace string) string {
+	return fmt.Sprintf("/app/workspaces/%s/create-tag", workspace)
+}
+
+func DeleteTagWorkspace(workspace string) string {
+	return fmt.Sprintf("/app/workspaces/%s/delete-tag", workspace)
+}
diff --git a/http/html/static/css/forms.css b/http/html/static/css/forms.css
index 8d39aba44..46c455a65 100644
--- a/http/html/static/css/forms.css
+++ b/http/html/static/css/forms.css
@@ -115,7 +115,36 @@ button:disabled, button:disabled:hover {
   title: "foo";
 }
 
+
+button.cross {
+  padding: 0.2em 0.5em;
+  background: #ed6969;
+  color: white;
+  font-weight: 700;
+  line-height: 1rem;
+}
+
 input.error {
   border-color: #e72633;
   box-shadow: 0 0 0 .125em rgba(229, 43, 37, 0.5);
 }
+
+/*
+ * Workspace listing
+ */
+
+.workspace-tag-filter-checkbox {
+  display: none;
+}
+
+.workspace-tag-filter-label {
+  background: #c0c0c0;
+  color: white;
+  padding: 0.2em;
+  font-size: 0.9em;
+  font-weight: 700;
+}
+
+.workspace-tag-filter-checkbox:checked ~ .workspace-tag-filter-label {
+  background: #2f35ab;
+}
diff --git a/http/html/static/css/main.css b/http/html/static/css/main.css
index 1e43e26c5..c508c6815 100644
--- a/http/html/static/css/main.css
+++ b/http/html/static/css/main.css
@@ -162,10 +162,10 @@ header nav {
 }
 
 /*
- * Workspace permissions
+ * Workspace settings
  */
 
-.permissions-container {
+.settings-container {
   display: flex;
   flex-direction: column;
   gap: 0.8em;
@@ -173,7 +173,7 @@ header nav {
   border-top: 1px solid var(--faint-grey);
 }
 
-.permissions-container .container-header-title {
+.settings-container .container-header-title {
   font-size: 1.2em;
   font-weight: bolder;
 }
@@ -188,24 +188,24 @@ header nav {
   color: grey;
 }
 
-.permissions-container table {
+.settings-container table {
   text-align: left;
   font-size: 0.9em;
   border-collapse: collapse;
   letter-spacing: 1px;
 }
 
-.permissions-container thead {
+.settings-container thead {
   background-color: var(--faint-grey);
   border-top: 1px solid #bac1cc;
   border-bottom: 1px solid #bac1cc;
 }
 
-.permissions-container td, th {
+.settings-container td, th {
   padding: 10px;
 }
 
-.permissions-container tbody tr {
+.settings-container tbody tr {
   border-bottom: 1px solid #dce0e6;
 }
 
@@ -215,6 +215,34 @@ header nav {
   gap: 1em;
 }
 
+.workspace-tag {
+  background: #2f35ab;
+  color: white;
+  padding: 0.2em;
+  font-size: 0.9em;
+  font-weight: 700;
+}
+
+/* list of tags on the main workspace page */
+.workspace-tags-list {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 0.3em;
+}
+
+/* workspace-tag-settings-list is the list of tags on the
+ * workspace settings page */
+.workspace-tag-settings-list {
+  display: flex;
+  flex-direction: row;
+  gap: 1em;
+}
+
+.workspace-tag-settings-list form {
+  line-height: 1em;
+}
+
 /*
  * Variables table
  */
@@ -423,6 +451,7 @@ a.show-underline {
   display: flex;
   align-items: center;
   gap: 0.5em;
+  padding: 0.5em;
 }
 
 .workspace-lock form {
@@ -438,6 +467,10 @@ a.show-underline {
   background: #d4fdd4;
 }
 
+.workspace-lock-info {
+  font-size: 0.9em;
+}
+
 /* color-coded run status field */
 .status {
   font-size: 1.1rem;
@@ -527,6 +560,34 @@ a.show-underline {
   font-size: 1.2em;
 }
 
+/* two-column is a two column layout for the main content, using flex-box. */
+.two-column {
+  display: flex;
+  gap: 2em;
+  flex-direction: row;
+}
+
+.two-column-main-column {
+  flex-grow: 1;
+}
+
+.two-column-side-column {
+  flex-basis: 13em;
+
+  display: flex;
+  gap: 0.7em;
+  flex-direction: column;
+}
+
+.two-column-side-column > * {
+  padding: 0.5em;
+  background: #f6f6f6;
+}
+
+.two-column-side-column h5 {
+  margin-block-start: 0em;
+}
+
 /* data is a nugget of factual information, e.g. terraform version */
 .data {
   font-family: var(--alt-font);
diff --git a/http/html/static/templates/content/workspace_edit.tmpl b/http/html/static/templates/content/workspace_edit.tmpl
index 715f9c10f..1f3e07c02 100644
--- a/http/html/static/templates/content/workspace_edit.tmpl
+++ b/http/html/static/templates/content/workspace_edit.tmpl
@@ -18,6 +18,8 @@
   {{ $canSetPermission := $.CurrentUser.CanAccessWorkspace .SetWorkspacePermissionAction .Policy }}
   {{ $canUnsetPermission := $.CurrentUser.CanAccessWorkspace .UnsetWorkspacePermissionAction .Policy }}
   {{ $canCreateRun := $.CurrentUser.CanAccessWorkspace .CreateRunAction .Policy }}
+  {{ $canAddTag := $.CurrentUser.CanAccessWorkspace .AddTagsAction .Policy }}
+  {{ $canRemoveTag := $.CurrentUser.CanAccessWorkspace .RemoveTagsAction .Policy }}
   <div>
     {{ with .Workspace.Connection }}
       <form action="{{ disconnectWorkspacePath $.Workspace.ID }}" method="POST">
@@ -70,7 +72,35 @@
       <button {{ insufficient $canUpdate }}>Save changes</button>
     </div>
   </form>
-  <div class="permissions-container">
+  <div class="settings-container">
+    <h3>Tags</h3>
+    <div class="workspace-tag-settings-list">
+      {{ range .Workspace.Tags }}
+        <form action="{{ deleteTagWorkspacePath $.Workspace.ID }}" method="POST">
+          <span class="workspace-tag">{{ . }}</span>
+          <input type="hidden" name="tag_name" id="remove-tag-name" value="{{ . }}" required>
+          <button id="button-remove-tag-{{ . }}" class="delete cross" {{ insufficient $canAddTag }}>x</button>
+        </form>
+      {{ end }}
+    </div>
+    <form action="{{ createTagWorkspacePath .Workspace.ID }}" method="POST">
+      <div class="field">
+        <input type="text" name="tag_name" id="new-tag-name" value="" required>
+        <button {{ insufficient $canAddTag }}>Add new tag</button>
+      </div>
+    </form>
+    <form action="{{ createTagWorkspacePath .Workspace.ID }}" method="POST">
+      <select name="tag_name" id="existing-tag-name">
+        {{ range .UnassignedTags }}
+          <option value="{{ . }}">{{ . }}</option>
+        {{ else }}
+          <option value="" disabled>no tags found</option>
+        {{ end }}
+      </select>
+      <button {{ insufficient $canAddTag }}>Add existing tag</button>
+    </form>
+  </div>
+  <div class="settings-container" id="permissions-container">
     <h3>Permissions</h3>
     <div>
       <table>
@@ -138,7 +168,7 @@
         </tbody>
       </table>
     </div>
-    <div class="permissions-container">
+    <div class="settings-container">
       <h3>Advanced</h3>
       <form action="{{ startRunWorkspacePath .Workspace.ID }}" method="POST">
         <button id="queue-destroy-plan-button" class="delete" {{ insufficient $canCreateRun }} onclick="return confirm('This will destroy all infrastructure in this workspace. Please confirm.')">
diff --git a/http/html/static/templates/content/workspace_get.tmpl b/http/html/static/templates/content/workspace_get.tmpl
index e80a02b70..12334d021 100644
--- a/http/html/static/templates/content/workspace_get.tmpl
+++ b/http/html/static/templates/content/workspace_get.tmpl
@@ -14,28 +14,9 @@
       watchWorkspaceUpdates({{ watchWorkspacePath .Workspace.ID }});
     });
   </script>
-  <div class="content-menu">
-    <div class="workspace-info">
+  <div class="two-column">
+    <div class="two-column-main-column">
       <div>{{ template "identifier" .Workspace }}</span></div>
-      <div>Terraform Version: <span class="data">v{{ .Workspace.TerraformVersion }}</span></div>
-      <div class="workspace-lock">
-        {{ with .LockButton }}
-          <span class="workspace-lock-status workspace-lock-status-{{ .State }}" title="{{ .Tooltip }}">{{ title .State }}</span>
-          <form action="{{ .Action }}" method="POST"><button  title="{{ .Tooltip }}" {{ disabled .Disabled }}>{{ .Text }}</button></form>
-        {{ end }}
-      </div>
-      <form id="workspace-start-run-form" action="{{ startRunWorkspacePath .Workspace.ID }}" method="POST">
-        <select name="strategy" id="start-run-strategy" onchange="this.form.submit()">
-          <option value="" selected>--start run--</option>
-          <option value="plan-only">plan only</option>
-          <option value="plan-and-apply">plan and apply</option>
-        </select>
-      </form>
-    </div>
-    {{ with .Workspace.Connection }}
-      <div>Connected to <span class="data">{{ .Repo }} ({{ $.VCSProvider.CloudConfig }})</span></div>
-    {{ end }}
-    <div>
       <h3>Latest Run</h3>
       <div id="latest-run">
         {{ if not .Workspace.LatestRun }}
@@ -43,6 +24,41 @@
         {{ end }}
       </div>
     </div>
+    <div class="two-column-side-column">
+      <div class="actions-container">
+        <h5>Actions</h5>
+        <form id="workspace-start-run-form" action="{{ startRunWorkspacePath .Workspace.ID }}" method="POST">
+          <select name="strategy" id="start-run-strategy" onchange="this.form.submit()">
+            <option value="" selected>-- start run --</option>
+            <option value="plan-only">plan only</option>
+            <option value="plan-and-apply">plan and apply</option>
+          </select>
+        </form>
+      </div>
+      <div class="terraform-version-container"><h5>Terraform Version</h5>v{{ .Workspace.TerraformVersion }}</div>
+      <div class="workspace-lock-container">
+        <h5>Locking</h5>
+        {{ with .LockButton }}
+          <div class="workspace-lock workspace-lock-status-{{ .State }}">
+            <span title="{{ .Tooltip }}">{{ title .State }}</span>
+            <form action="{{ .Action }}" method="POST"><button  title="{{ .Tooltip }}" {{ disabled .Disabled }}>{{ .Text }}</button></form>
+          </div>
+          <span class="workspace-lock-info">{{ .Tooltip }}</span>
+        {{ end }}
+      </div>
+      {{ with .Workspace.Connection }}
+        <div>Connected to <span class="data">{{ .Repo }} ({{ $.VCSProvider.CloudConfig }})</span></div>
+      {{ end }}
+      <div>
+        <h5>Tags</h5>
+        <div class="workspace-tags-list">
+        {{ range .Workspace.Tags }}
+          <span class="workspace-tag">{{ . }}</span>
+        {{ end }}
+        </div>
+      </div>
+    </div>
+    </div>
     {{ with .Workspace.LatestRun }}
       <script type="text/javascript">
           fetch({{ widgetRunPath .ID }})
diff --git a/http/html/static/templates/content/workspace_list.tmpl b/http/html/static/templates/content/workspace_list.tmpl
index d66dfb157..c0cf03a2c 100644
--- a/http/html/static/templates/content/workspace_list.tmpl
+++ b/http/html/static/templates/content/workspace_list.tmpl
@@ -12,6 +12,18 @@
 {{ end }}
 
 {{ define "content" }}
+    <form id="tag-filter-form" action="{{ workspacesPath .Organization }}" method="GET">
+      <div class="workspace-tags-list">
+        {{ range $k, $v := .TagFilters }}
+          <div>
+            <input id="workspace-tag-filter-{{ $k }}" class="workspace-tag-filter-checkbox" name="search[tags]" value="{{ $k }}" type="checkbox" {{ checked $v }} onchange="this.form.submit()" />
+            <label for="workspace-tag-filter-{{ $k }}" class="workspace-tag-filter-label">
+              {{ $k }}
+            </label>
+          </div>
+        {{ end }}
+      </div>
+    </form>
   {{ template "content-list" . }}
 {{ end }}
 
diff --git a/integration/tag_e2e_test.go b/integration/tag_e2e_test.go
new file mode 100644
index 000000000..70ac8b4d8
--- /dev/null
+++ b/integration/tag_e2e_test.go
@@ -0,0 +1,109 @@
+package integration
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/chromedp/cdproto/input"
+	"github.com/chromedp/chromedp"
+	expect "github.com/google/goexpect"
+	"github.com/leg100/otf"
+	"github.com/leg100/otf/workspace"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestIntegration_TagsE2E demonstrates end-to-end usage of workspace tags.
+func TestIntegration_TagsE2E(t *testing.T) {
+	t.Parallel()
+
+	daemon := setup(t, nil)
+	user, ctx := daemon.createUserCtx(t, ctx)
+	org := daemon.createOrganization(t, ctx)
+
+	// create a root module with a cloud backend configured to use workspaces
+	// with foo and bar tags.
+	root := createRootModule(t, fmt.Sprintf(`
+terraform {
+  cloud {
+	hostname = "%s"
+	organization = "%s"
+
+	workspaces {
+		tags = ["foo", "bar"]
+	}
+  }
+}
+resource "null_resource" "tags_e2e" {}
+`, daemon.Hostname(), org.Name))
+
+	// run terraform init
+	_, token := daemon.createToken(t, ctx, user)
+	e, tferr, err := expect.SpawnWithArgs(
+		[]string{"terraform", "-chdir=" + root, "init", "-no-color"},
+		time.Minute,
+		expect.PartialMatch(true),
+		expect.SetEnv(
+			append(envs, otf.CredentialEnv(daemon.Hostname(), token)),
+		),
+	)
+	require.NoError(t, err)
+	defer e.Close()
+
+	// create tagged workspace when prompted
+	e.ExpectBatch([]expect.Batcher{
+		&expect.BExp{R: "Enter a value: "},
+		&expect.BSnd{S: "tagged\n"},
+		&expect.BExp{R: "Terraform Cloud has been successfully initialized!"},
+	}, time.Second*5)
+	// Terraform should return with exit code 0
+	require.NoError(t, <-tferr, e.String)
+
+	// confirm tagged workspace has been created
+	got, err := daemon.ListWorkspaces(ctx, workspace.ListOptions{
+		Organization: otf.String(org.Name),
+		Tags:         []string{"foo", "bar"},
+	})
+	require.NoError(t, err)
+	require.Equal(t, 1, len(got.Items))
+	if assert.Equal(t, 2, len(got.Items[0].Tags)) {
+		assert.Contains(t, got.Items[0].Tags, "foo")
+		assert.Contains(t, got.Items[0].Tags, "bar")
+	}
+
+	// test UI management of tags
+	browser := createBrowserCtx(t)
+	err = chromedp.Run(browser, chromedp.Tasks{
+		newSession(t, ctx, daemon.Hostname(), user.Username, daemon.Secret),
+		chromedp.Navigate(workspaceURL(daemon.Hostname(), org.Name, "tagged")),
+		// confirm workspace page lists both tags
+		chromedp.WaitVisible(`//*[@class='workspace-tag'][contains(text(),'foo')]`),
+		chromedp.WaitVisible(`//*[@class='workspace-tag'][contains(text(),'bar')]`),
+		// go to workspace settings
+		chromedp.Click(`//a[text()='settings']`, chromedp.NodeVisible),
+		screenshot(t),
+		// remove bar tag
+		chromedp.Click(`//button[@id='button-remove-tag-bar']`, chromedp.NodeVisible),
+		screenshot(t),
+		matchText(t, ".flash-success", "removed tag: bar"),
+		// add new tag
+		chromedp.Focus("input#new-tag-name", chromedp.NodeVisible),
+		input.InsertText("baz"),
+		chromedp.Click(`//button[text()='Add new tag']`, chromedp.NodeVisible),
+		screenshot(t),
+		matchText(t, ".flash-success", "created tag: baz"),
+		// go to workspace listing
+		chromedp.Click(`//div[@class='container-header-title']//a[text()='workspaces']`, chromedp.NodeVisible),
+		screenshot(t),
+		// filter by tag foo
+		chromedp.Click(`//label[@for='workspace-tag-filter-foo']`, chromedp.NodeVisible),
+		screenshot(t),
+		// filter by tag bar
+		chromedp.Click(`//label[@for='workspace-tag-filter-baz']`, chromedp.NodeVisible),
+		screenshot(t),
+		// confirm workspace listing contains tagged workspace
+		chromedp.WaitVisible(`//div[@id='content-list']//a[text()='tagged']`, chromedp.NodeVisible),
+	})
+	require.NoError(t, err)
+}
diff --git a/integration/tag_service_test.go b/integration/tag_service_test.go
new file mode 100644
index 000000000..9ec4ee9e5
--- /dev/null
+++ b/integration/tag_service_test.go
@@ -0,0 +1,150 @@
+package integration
+
+import (
+	"testing"
+
+	"github.com/leg100/otf/workspace"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestIntegration_TagService(t *testing.T) {
+	t.Parallel()
+
+	t.Run("add tags to workspace", func(t *testing.T) {
+		svc := setup(t, nil)
+		org := svc.createOrganization(t, ctx)
+		ws := svc.createWorkspace(t, ctx, org)
+		err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{
+			{Name: "foo"},
+			{Name: "bar"},
+			{Name: "baz"},
+		})
+		require.NoError(t, err)
+
+		// should have 3 tags across org
+		got, err := svc.ListTags(ctx, org.Name, workspace.ListTagsOptions{})
+		require.NoError(t, err)
+		assert.Equal(t, 3, len(got.Items))
+
+		// should have 3 tags on ws
+		got, err = svc.ListWorkspaceTags(ctx, ws.ID, workspace.ListWorkspaceTagsOptions{})
+		require.NoError(t, err)
+		assert.Equal(t, 3, len(got.Items))
+
+		t.Run("add same tags to another workspace", func(t *testing.T) {
+			ws := svc.createWorkspace(t, ctx, org)
+			err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{
+				{Name: "foo"},
+				{Name: "bar"},
+				{Name: "baz"},
+			})
+			require.NoError(t, err)
+
+			// should still have 3 tags across org
+			got, err := svc.ListTags(ctx, org.Name, workspace.ListTagsOptions{})
+			require.NoError(t, err)
+			assert.Equal(t, 3, len(got.Items))
+
+			// should have 3 tags on ws
+			got, err = svc.ListWorkspaceTags(ctx, ws.ID, workspace.ListWorkspaceTagsOptions{})
+			require.NoError(t, err)
+			assert.Equal(t, 3, len(got.Items))
+		})
+
+		t.Run("invalid tag spec", func(t *testing.T) {
+			err = svc.AddTags(ctx, ws.ID, []workspace.TagSpec{{}})
+			assert.Equal(t, workspace.ErrInvalidTagSpec, err)
+		})
+	})
+
+	t.Run("remove tags from workspace", func(t *testing.T) {
+		svc := setup(t, nil)
+		ws := svc.createWorkspace(t, ctx, nil)
+		err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{
+			{Name: "foo"},
+			{Name: "bar"},
+			{Name: "baz"},
+		})
+		require.NoError(t, err)
+
+		got, err := svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{})
+		require.NoError(t, err)
+		assert.Equal(t, 3, len(got.Items))
+
+		err = svc.RemoveTags(ctx, ws.ID, []workspace.TagSpec{
+			{Name: "foo"},
+			{Name: "doesnotexist"},
+			{Name: "bar"},
+			{Name: "baz"},
+			{Name: "doesnotexist"},
+		})
+		require.NoError(t, err)
+
+		got, err = svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{})
+		require.NoError(t, err)
+		assert.Empty(t, got.Items)
+	})
+
+	t.Run("tag workspaces", func(t *testing.T) {
+		svc := setup(t, nil)
+		org := svc.createOrganization(t, ctx)
+		ws1 := svc.createWorkspace(t, ctx, org)
+		ws2 := svc.createWorkspace(t, ctx, org)
+		ws3 := svc.createWorkspace(t, ctx, org)
+
+		// create tag first by adding tag to ws1
+		err := svc.AddTags(ctx, ws1.ID, []workspace.TagSpec{{Name: "foo"}})
+		require.NoError(t, err)
+
+		// retrieve created tag
+		list, err := svc.ListTags(ctx, ws1.Organization, workspace.ListTagsOptions{})
+		require.NoError(t, err)
+		require.Equal(t, 1, len(list.Items))
+		tag := list.Items[0]
+
+		// add tag to ws2 and ws3
+		err = svc.TagWorkspaces(ctx, tag.ID, []string{ws2.ID, ws3.ID})
+		require.NoError(t, err)
+
+		// check ws2 is tagged
+		got, err := svc.ListWorkspaceTags(ctx, ws2.ID, workspace.ListWorkspaceTagsOptions{})
+		require.NoError(t, err)
+		if assert.Equal(t, 1, len(got.Items)) {
+			assert.Equal(t, got.Items[0].Organization, ws2.Organization)
+		}
+
+		// check ws3 is tagged
+		got, err = svc.ListWorkspaceTags(ctx, ws3.ID, workspace.ListWorkspaceTagsOptions{})
+		require.NoError(t, err)
+		if assert.Equal(t, 1, len(got.Items)) {
+			assert.Equal(t, got.Items[0].Organization, ws3.Organization)
+		}
+	})
+
+	t.Run("delete tags from organization", func(t *testing.T) {
+		svc := setup(t, nil)
+		ws := svc.createWorkspace(t, ctx, nil)
+		err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{
+			{Name: "foo"},
+			{Name: "bar"},
+			{Name: "baz"},
+		})
+		require.NoError(t, err)
+
+		list, err := svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{})
+		require.NoError(t, err)
+		require.Equal(t, 3, len(list.Items))
+
+		err = svc.DeleteTags(ctx, ws.Organization, []string{
+			list.Items[0].ID,
+			list.Items[1].ID,
+			list.Items[2].ID,
+		})
+		require.NoError(t, err)
+
+		got, err := svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{})
+		require.NoError(t, err)
+		assert.Empty(t, got.Items)
+	})
+}
diff --git a/integration/test_helpers.go b/integration/test_helpers.go
index cf4233e5c..0ab120eb9 100644
--- a/integration/test_helpers.go
+++ b/integration/test_helpers.go
@@ -80,8 +80,12 @@ resource "null_resource" "e2e" {}
 		config += cfg
 	}
 
+	return createRootModule(t, config)
+}
+
+func createRootModule(t *testing.T, tfconfig string) string {
 	root := t.TempDir()
-	err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(config), 0o600)
+	err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(tfconfig), 0o600)
 	require.NoError(t, err)
 
 	return root
diff --git a/integration/workspace_test.go b/integration/workspace_test.go
index 0076e7ac8..8b61a4ea1 100644
--- a/integration/workspace_test.go
+++ b/integration/workspace_test.go
@@ -1,7 +1,6 @@
 package integration
 
 import (
-	"context"
 	"testing"
 
 	"github.com/google/uuid"
@@ -18,9 +17,6 @@ import (
 func TestWorkspace(t *testing.T) {
 	t.Parallel()
 
-	// perform all actions as superuser
-	ctx := otf.AddSubjectToContext(context.Background(), &auth.SiteAdmin)
-
 	t.Run("create", func(t *testing.T) {
 		svc := setup(t, nil)
 		org := svc.createOrganization(t, ctx)
@@ -233,6 +229,58 @@ func TestWorkspace(t *testing.T) {
 		}
 	})
 
+	t.Run("list by tag", func(t *testing.T) {
+		svc := setup(t, nil)
+		org := svc.createOrganization(t, ctx)
+		ws1, err := svc.CreateWorkspace(ctx, workspace.CreateOptions{
+			Name:         otf.String(uuid.NewString()),
+			Organization: &org.Name,
+			Tags:         []workspace.TagSpec{{Name: "foo"}},
+		})
+		require.NoError(t, err)
+		ws2, err := svc.CreateWorkspace(ctx, workspace.CreateOptions{
+			Name:         otf.String(uuid.NewString()),
+			Organization: &org.Name,
+			Tags:         []workspace.TagSpec{{Name: "foo"}, {Name: "bar"}},
+		})
+		require.NoError(t, err)
+
+		tests := []struct {
+			name string
+			tags []string
+			want func(*testing.T, *workspace.WorkspaceList)
+		}{
+			{
+				name: "foo",
+				tags: []string{"foo"},
+				want: func(t *testing.T, l *workspace.WorkspaceList) {
+					assert.Equal(t, 2, len(l.Items))
+					assert.Contains(t, l.Items, ws1)
+					assert.Contains(t, l.Items, ws2)
+				},
+			},
+			{
+				name: "foo and bar",
+				tags: []string{"foo", "bar"},
+				want: func(t *testing.T, l *workspace.WorkspaceList) {
+					assert.Equal(t, 1, len(l.Items))
+					assert.Contains(t, l.Items, ws2)
+				},
+			},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				results, err := svc.ListWorkspaces(ctx, workspace.ListOptions{
+					Tags: tt.tags,
+				})
+				require.NoError(t, err)
+
+				tt.want(t, results)
+			})
+		}
+	})
+
 	t.Run("list by user", func(t *testing.T) {
 		svc := setup(t, nil)
 		org := svc.createOrganization(t, ctx)
diff --git a/organization/db.go b/organization/db.go
index f0ed7fbc2..e93cd4c7e 100644
--- a/organization/db.go
+++ b/organization/db.go
@@ -98,7 +98,7 @@ func (db *pgdb) list(ctx context.Context, opts OrganizationListOptions) (*Organi
 
 	return &OrganizationList{
 		Items:      items,
-		Pagination: otf.NewPagination(opts.ListOptions, *count),
+		Pagination: otf.NewPagination(opts.ListOptions, count),
 	}, nil
 }
 
diff --git a/otf.go b/otf.go
index dd73ead53..1c8f81dce 100644
--- a/otf.go
+++ b/otf.go
@@ -201,3 +201,18 @@ func Index[E comparable](s []E, v E) int {
 func Contains[E comparable](s []E, v E) bool {
 	return Index(s, v) >= 0
 }
+
+// DiffStrings returns the elements in `a` that aren't in `b`.
+func DiffStrings(a, b []string) []string {
+	mb := make(map[string]struct{}, len(b))
+	for _, x := range b {
+		mb[x] = struct{}{}
+	}
+	var diff []string
+	for _, x := range a {
+		if _, found := mb[x]; !found {
+			diff = append(diff, x)
+		}
+	}
+	return diff
+}
diff --git a/policy/policy.go b/policy/policy.go
deleted file mode 100644
index aa03f7675..000000000
--- a/policy/policy.go
+++ /dev/null
@@ -1,3 +0,0 @@
-// Package policy policies access to workspaces and manages workspace
-// permissions
-package policy
diff --git a/policy/service.go b/policy/service.go
deleted file mode 100644
index abcc66cef..000000000
--- a/policy/service.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package policy
-
-import (
-	"context"
-
-	"github.com/leg100/otf"
-	"github.com/leg100/otf/logr"
-	"github.com/leg100/otf/rbac"
-)
-
-type (
-	PolicyService = Service
-
-	Service interface {
-		GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error)
-		SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error
-		UnsetPermission(ctx context.Context, workspaceID, team string) error
-	}
-
-	service struct {
-		logr.Logger
-		db *pgdb
-	}
-
-	Options struct {
-		otf.DB
-		logr.Logger
-	}
-)
-
-func NewService(opts Options) *service {
-	return &service{
-		Logger: opts.Logger,
-		db:     &pgdb{opts.DB},
-	}
-}
-
-// GetPolicy retrieves a workspace policy.
-//
-// NOTE: no authz protects this endpoint because it's used in the process of making
-// authz decisions.
-func (s *service) GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) {
-	return s.db.getWorkspacePolicy(ctx, workspaceID)
-}
-
-func (s *service) SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error {
-	subject, err := s.CanAccess(ctx, rbac.SetWorkspacePermissionAction, workspaceID)
-	if err != nil {
-		return err
-	}
-
-	if err := s.db.setWorkspacePermission(ctx, workspaceID, team, role); err != nil {
-		s.Error(err, "setting workspace permission", "subject", subject, "workspace", workspaceID)
-		return err
-	}
-
-	s.V(0).Info("set workspace permission", "team", team, "role", role, "subject", subject, "workspace", workspaceID)
-
-	// TODO: publish event
-
-	return nil
-}
-
-func (s *service) UnsetPermission(ctx context.Context, workspaceID, team string) error {
-	subject, err := s.CanAccess(ctx, rbac.UnsetWorkspacePermissionAction, workspaceID)
-	if err != nil {
-		s.Error(err, "unsetting workspace permission", "team", team, "subject", subject, "workspace", workspaceID)
-		return err
-	}
-
-	s.V(0).Info("unset workspace permission", "team", team, "subject", subject, "workspace", workspaceID)
-	// TODO: publish event
-	return s.db.unsetWorkspacePermission(ctx, workspaceID, team)
-}
-
-// CanAccess determines whether the subject (in the ctx) is permitted to carry
-// out the specified action on the workspace with the given id.
-func (s *service) CanAccess(ctx context.Context, action rbac.Action, workspaceID string) (otf.Subject, error) {
-	subj, err := otf.SubjectFromContext(ctx)
-	if err != nil {
-		return nil, err
-	}
-	policy, err := s.db.getWorkspacePolicy(ctx, workspaceID)
-	if err != nil {
-		return nil, otf.ErrResourceNotFound
-	}
-	if subj.CanAccessWorkspace(action, policy) {
-		return subj, nil
-	}
-	s.Error(nil, "unauthorized action", "workspace", workspaceID, "organization", policy.Organization, "action", action, "subject", subj)
-	return nil, otf.ErrAccessNotPermitted
-}
diff --git a/rbac/action.go b/rbac/action.go
index 4aad0cce2..d9713a540 100644
--- a/rbac/action.go
+++ b/rbac/action.go
@@ -65,6 +65,13 @@ const (
 	UnsetWorkspacePermissionAction
 	UpdateWorkspaceAction
 
+	ListTagsAction
+	DeleteTagsAction
+	TagWorkspacesAction
+	AddTagsAction
+	RemoveTagsAction
+	ListWorkspaceTags
+
 	LockWorkspaceAction
 	UnlockWorkspaceAction
 	ForceUnlockWorkspaceAction
diff --git a/rbac/action_string.go b/rbac/action_string.go
index 37a14564a..d730b3824 100644
--- a/rbac/action_string.go
+++ b/rbac/action_string.go
@@ -58,37 +58,43 @@ func _() {
 	_ = x[SetWorkspacePermissionAction-47]
 	_ = x[UnsetWorkspacePermissionAction-48]
 	_ = x[UpdateWorkspaceAction-49]
-	_ = x[LockWorkspaceAction-50]
-	_ = x[UnlockWorkspaceAction-51]
-	_ = x[ForceUnlockWorkspaceAction-52]
-	_ = x[CreateStateVersionAction-53]
-	_ = x[ListStateVersionsAction-54]
-	_ = x[GetStateVersionAction-55]
-	_ = x[DeleteStateVersionAction-56]
-	_ = x[RollbackStateVersionAction-57]
-	_ = x[DownloadStateAction-58]
-	_ = x[GetStateVersionOutputAction-59]
-	_ = x[CreateConfigurationVersionAction-60]
-	_ = x[ListConfigurationVersionsAction-61]
-	_ = x[GetConfigurationVersionAction-62]
-	_ = x[DownloadConfigurationVersionAction-63]
-	_ = x[DeleteConfigurationVersionAction-64]
-	_ = x[CreateUserAction-65]
-	_ = x[ListUsersAction-66]
-	_ = x[GetUserAction-67]
-	_ = x[DeleteUserAction-68]
-	_ = x[CreateTeamAction-69]
-	_ = x[UpdateTeamAction-70]
-	_ = x[GetTeamAction-71]
-	_ = x[ListTeamsAction-72]
-	_ = x[DeleteTeamAction-73]
-	_ = x[AddTeamMembershipAction-74]
-	_ = x[RemoveTeamMembershipAction-75]
+	_ = x[ListTagsAction-50]
+	_ = x[DeleteTagsAction-51]
+	_ = x[TagWorkspacesAction-52]
+	_ = x[AddTagsAction-53]
+	_ = x[RemoveTagsAction-54]
+	_ = x[ListWorkspaceTags-55]
+	_ = x[LockWorkspaceAction-56]
+	_ = x[UnlockWorkspaceAction-57]
+	_ = x[ForceUnlockWorkspaceAction-58]
+	_ = x[CreateStateVersionAction-59]
+	_ = x[ListStateVersionsAction-60]
+	_ = x[GetStateVersionAction-61]
+	_ = x[DeleteStateVersionAction-62]
+	_ = x[RollbackStateVersionAction-63]
+	_ = x[DownloadStateAction-64]
+	_ = x[GetStateVersionOutputAction-65]
+	_ = x[CreateConfigurationVersionAction-66]
+	_ = x[ListConfigurationVersionsAction-67]
+	_ = x[GetConfigurationVersionAction-68]
+	_ = x[DownloadConfigurationVersionAction-69]
+	_ = x[DeleteConfigurationVersionAction-70]
+	_ = x[CreateUserAction-71]
+	_ = x[ListUsersAction-72]
+	_ = x[GetUserAction-73]
+	_ = x[DeleteUserAction-74]
+	_ = x[CreateTeamAction-75]
+	_ = x[UpdateTeamAction-76]
+	_ = x[GetTeamAction-77]
+	_ = x[ListTeamsAction-78]
+	_ = x[DeleteTeamAction-79]
+	_ = x[AddTeamMembershipAction-80]
+	_ = x[RemoveTeamMembershipAction-81]
 }
 
-const _Action_name = "WatchActionCreateOrganizationActionUpdateOrganizationActionGetOrganizationActionListOrganizationsActionGetEntitlementsActionDeleteOrganizationActionCreateVCSProviderActionGetVCSProviderActionListVCSProvidersActionDeleteVCSProviderActionCreateAgentTokenActionListAgentTokensActionDeleteAgentTokenActionCreateRunTokenActionCreateModuleActionCreateModuleVersionActionUpdateModuleActionListModulesActionGetModuleActionDeleteModuleActionDeleteModuleVersionActionCreateVariableActionUpdateVariableActionListVariablesActionGetVariableActionDeleteVariableActionGetRunActionListRunsActionApplyRunActionCreateRunActionDiscardRunActionDeleteRunActionCancelRunActionEnqueuePlanActionStartPhaseActionFinishPhaseActionPutChunkActionTailLogsActionGetPlanFileActionUploadPlanFileActionGetLockFileActionUploadLockFileActionListWorkspacesActionGetWorkspaceActionCreateWorkspaceActionDeleteWorkspaceActionSetWorkspacePermissionActionUnsetWorkspacePermissionActionUpdateWorkspaceActionLockWorkspaceActionUnlockWorkspaceActionForceUnlockWorkspaceActionCreateStateVersionActionListStateVersionsActionGetStateVersionActionDeleteStateVersionActionRollbackStateVersionActionDownloadStateActionGetStateVersionOutputActionCreateConfigurationVersionActionListConfigurationVersionsActionGetConfigurationVersionActionDownloadConfigurationVersionActionDeleteConfigurationVersionActionCreateUserActionListUsersActionGetUserActionDeleteUserActionCreateTeamActionUpdateTeamActionGetTeamActionListTeamsActionDeleteTeamActionAddTeamMembershipActionRemoveTeamMembershipAction"
+const _Action_name = "WatchActionCreateOrganizationActionUpdateOrganizationActionGetOrganizationActionListOrganizationsActionGetEntitlementsActionDeleteOrganizationActionCreateVCSProviderActionGetVCSProviderActionListVCSProvidersActionDeleteVCSProviderActionCreateAgentTokenActionListAgentTokensActionDeleteAgentTokenActionCreateRunTokenActionCreateModuleActionCreateModuleVersionActionUpdateModuleActionListModulesActionGetModuleActionDeleteModuleActionDeleteModuleVersionActionCreateVariableActionUpdateVariableActionListVariablesActionGetVariableActionDeleteVariableActionGetRunActionListRunsActionApplyRunActionCreateRunActionDiscardRunActionDeleteRunActionCancelRunActionEnqueuePlanActionStartPhaseActionFinishPhaseActionPutChunkActionTailLogsActionGetPlanFileActionUploadPlanFileActionGetLockFileActionUploadLockFileActionListWorkspacesActionGetWorkspaceActionCreateWorkspaceActionDeleteWorkspaceActionSetWorkspacePermissionActionUnsetWorkspacePermissionActionUpdateWorkspaceActionListTagsActionDeleteTagsActionTagWorkspacesActionAddTagsActionRemoveTagsActionListWorkspaceTagsLockWorkspaceActionUnlockWorkspaceActionForceUnlockWorkspaceActionCreateStateVersionActionListStateVersionsActionGetStateVersionActionDeleteStateVersionActionRollbackStateVersionActionDownloadStateActionGetStateVersionOutputActionCreateConfigurationVersionActionListConfigurationVersionsActionGetConfigurationVersionActionDownloadConfigurationVersionActionDeleteConfigurationVersionActionCreateUserActionListUsersActionGetUserActionDeleteUserActionCreateTeamActionUpdateTeamActionGetTeamActionListTeamsActionDeleteTeamActionAddTeamMembershipActionRemoveTeamMembershipAction"
 
-var _Action_index = [...]uint16{0, 11, 35, 59, 80, 103, 124, 148, 171, 191, 213, 236, 258, 279, 301, 321, 339, 364, 382, 399, 414, 432, 457, 477, 497, 516, 533, 553, 565, 579, 593, 608, 624, 639, 654, 671, 687, 704, 718, 732, 749, 769, 786, 806, 826, 844, 865, 886, 914, 944, 965, 984, 1005, 1031, 1055, 1078, 1099, 1123, 1149, 1168, 1195, 1227, 1258, 1287, 1321, 1353, 1369, 1384, 1397, 1413, 1429, 1445, 1458, 1473, 1489, 1512, 1538}
+var _Action_index = [...]uint16{0, 11, 35, 59, 80, 103, 124, 148, 171, 191, 213, 236, 258, 279, 301, 321, 339, 364, 382, 399, 414, 432, 457, 477, 497, 516, 533, 553, 565, 579, 593, 608, 624, 639, 654, 671, 687, 704, 718, 732, 749, 769, 786, 806, 826, 844, 865, 886, 914, 944, 965, 979, 995, 1014, 1027, 1043, 1060, 1079, 1100, 1126, 1150, 1173, 1194, 1218, 1244, 1263, 1290, 1322, 1353, 1382, 1416, 1448, 1464, 1479, 1492, 1508, 1524, 1540, 1553, 1568, 1584, 1607, 1633}
 
 func (i Action) String() string {
 	if i < 0 || i >= Action(len(_Action_index)-1) {
diff --git a/rbac/role.go b/rbac/role.go
index 35fc3251d..c020bef06 100644
--- a/rbac/role.go
+++ b/rbac/role.go
@@ -16,6 +16,7 @@ var (
 			GetTeamAction:         true,
 			ListTeamsAction:       true,
 			ListUsersAction:       true,
+			ListTagsAction:        true,
 		},
 	}
 
@@ -35,6 +36,7 @@ var (
 			ListVariablesAction:                true,
 			GetVariableAction:                  true,
 			WatchAction:                        true,
+			ListWorkspaceTags:                  true,
 		},
 	}
 
@@ -86,6 +88,8 @@ var (
 			CreateWorkspaceAction: true,
 			ListWorkspacesAction:  true,
 			UpdateWorkspaceAction: true,
+			AddTagsAction:         true,
+			RemoveTagsAction:      true,
 			// includes WorkspaceAdminRole perms too (see below)
 		},
 	}
diff --git a/repo/db.go b/repo/db.go
index 898981dfe..009e1899e 100644
--- a/repo/db.go
+++ b/repo/db.go
@@ -110,7 +110,7 @@ func (db *pgdb) deleteConnection(ctx context.Context, opts DisconnectOptions) (h
 	}
 }
 
-func (db *pgdb) countConnections(ctx context.Context, hookID uuid.UUID) (*int, error) {
+func (db *pgdb) countConnections(ctx context.Context, hookID uuid.UUID) (int, error) {
 	return db.CountRepoConnectionsByID(ctx, sql.UUID(hookID))
 }
 
@@ -126,8 +126,7 @@ func (db *pgdb) deleteHook(ctx context.Context, id uuid.UUID) (*hook, error) {
 // caller can use the transaction.
 func (db *pgdb) lock(ctx context.Context, callback func(*pgdb) error) error {
 	return db.Tx(ctx, func(tx otf.DB) error {
-		_, err := tx.Exec(ctx, "LOCK webhooks")
-		if err != nil {
+		if _, err := tx.Exec(ctx, "LOCK webhooks"); err != nil {
 			return err
 		}
 		return callback(newPGDB(tx, db.factory))
diff --git a/repo/service.go b/repo/service.go
index a492260ef..beb734aa8 100644
--- a/repo/service.go
+++ b/repo/service.go
@@ -148,7 +148,7 @@ func (s *service) Disconnect(ctx context.Context, opts DisconnectOptions) error
 		if err != nil {
 			return err
 		}
-		if *conns > 0 {
+		if conns > 0 {
 			return nil
 		}
 
diff --git a/run/db.go b/run/db.go
index 132295650..82279704f 100644
--- a/run/db.go
+++ b/run/db.go
@@ -183,9 +183,9 @@ func (db *pgdb) CreateApplyReport(ctx context.Context, runID string, report Reso
 
 func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, error) {
 	batch := &pgx.Batch{}
-	organizationName := "%"
+	organization := "%"
 	if opts.Organization != nil {
-		organizationName = *opts.Organization
+		organization = *opts.Organization
 	}
 	workspaceName := "%"
 	if opts.WorkspaceName != nil {
@@ -204,7 +204,7 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er
 		speculative = strconv.FormatBool(*opts.Speculative)
 	}
 	db.FindRunsBatch(batch, pggen.FindRunsParams{
-		OrganizationNames: []string{organizationName},
+		OrganizationNames: []string{organization},
 		WorkspaceNames:    []string{workspaceName},
 		WorkspaceIds:      []string{workspaceID},
 		Statuses:          statuses,
@@ -213,7 +213,7 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er
 		Offset:            opts.GetOffset(),
 	})
 	db.CountRunsBatch(batch, pggen.CountRunsParams{
-		OrganizationNames: []string{organizationName},
+		OrganizationNames: []string{organization},
 		WorkspaceNames:    []string{workspaceName},
 		WorkspaceIds:      []string{workspaceID},
 		Statuses:          statuses,
@@ -239,7 +239,7 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er
 
 	return &RunList{
 		Items:      items,
-		Pagination: otf.NewPagination(opts.ListOptions, *count),
+		Pagination: otf.NewPagination(opts.ListOptions, count),
 	}, nil
 }
 
diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go
index 55b4e5180..996e39798 100644
--- a/scheduler/scheduler.go
+++ b/scheduler/scheduler.go
@@ -171,7 +171,7 @@ func (s *scheduler) reinitialize(ctx context.Context) error {
 			q, ok := s.queues[payload.WorkspaceID]
 			if !ok {
 				// should never happen
-				s.Error(fmt.Errorf("workspace queue does not exist for run event"), "workspace", payload.WorkspaceID, "run", payload.ID)
+				s.Error(nil, "workspace queue does not exist for run event", "workspace", payload.WorkspaceID, "run", payload.ID, "event", event.Type)
 				continue
 			}
 			if err := q.handleEvent(ctx, event); err != nil {
diff --git a/sql/migrations/20230427144312_add_table_workspace_tags.sql b/sql/migrations/20230427144312_add_table_workspace_tags.sql
new file mode 100644
index 000000000..a2f1ab103
--- /dev/null
+++ b/sql/migrations/20230427144312_add_table_workspace_tags.sql
@@ -0,0 +1,18 @@
+-- +goose Up
+CREATE TABLE IF NOT EXISTS tags (
+    tag_id              TEXT NOT NULL,
+    name                TEXT NOT NULL,
+    organization_name   TEXT REFERENCES organizations (name) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
+                        PRIMARY KEY (tag_id),
+                        UNIQUE (organization_name, name)
+);
+
+CREATE TABLE IF NOT EXISTS workspace_tags (
+    tag_id          TEXT REFERENCES tags ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
+    workspace_id    TEXT REFERENCES workspaces ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
+                    UNIQUE (tag_id, workspace_id)
+);
+
+-- +goose Down
+DROP TABLE IF EXISTS workspace_tags;
+DROP TABLE IF EXISTS tags;
diff --git a/sql/pggen/agent_token.sql.go b/sql/pggen/agent_token.sql.go
index f91a19c74..6b00e9972 100644
--- a/sql/pggen/agent_token.sql.go
+++ b/sql/pggen/agent_token.sql.go
@@ -90,12 +90,12 @@ type Querier interface {
 	// FindConfigurationVersionsByWorkspaceIDScan scans the result of an executed FindConfigurationVersionsByWorkspaceIDBatch query.
 	FindConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) ([]FindConfigurationVersionsByWorkspaceIDRow, error)
 
-	CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (*int, error)
+	CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (int, error)
 	// CountConfigurationVersionsByWorkspaceIDBatch enqueues a CountConfigurationVersionsByWorkspaceID query into batch to be executed
 	// later by the batch.
 	CountConfigurationVersionsByWorkspaceIDBatch(batch genericBatch, workspaceID pgtype.Text)
 	// CountConfigurationVersionsByWorkspaceIDScan scans the result of an executed CountConfigurationVersionsByWorkspaceIDBatch query.
-	CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (*int, error)
+	CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (int, error)
 
 	// FindConfigurationVersionByID finds a configuration_version by its id.
 	//
@@ -284,12 +284,12 @@ type Querier interface {
 	// FindOrganizationsScan scans the result of an executed FindOrganizationsBatch query.
 	FindOrganizationsScan(results pgx.BatchResults) ([]FindOrganizationsRow, error)
 
-	CountOrganizations(ctx context.Context, names []string) (*int, error)
+	CountOrganizations(ctx context.Context, names []string) (int, error)
 	// CountOrganizationsBatch enqueues a CountOrganizations query into batch to be executed
 	// later by the batch.
 	CountOrganizationsBatch(batch genericBatch, names []string)
 	// CountOrganizationsScan scans the result of an executed CountOrganizationsBatch query.
-	CountOrganizationsScan(results pgx.BatchResults) (*int, error)
+	CountOrganizationsScan(results pgx.BatchResults) (int, error)
 
 	InsertOrganization(ctx context.Context, params InsertOrganizationParams) (pgconn.CommandTag, error)
 	// InsertOrganizationBatch enqueues a InsertOrganization query into batch to be executed
@@ -398,12 +398,12 @@ type Querier interface {
 	// InsertRepoConnectionScan scans the result of an executed InsertRepoConnectionBatch query.
 	InsertRepoConnectionScan(results pgx.BatchResults) (pgconn.CommandTag, error)
 
-	CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (*int, error)
+	CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (int, error)
 	// CountRepoConnectionsByIDBatch enqueues a CountRepoConnectionsByID query into batch to be executed
 	// later by the batch.
 	CountRepoConnectionsByIDBatch(batch genericBatch, webhookID pgtype.UUID)
 	// CountRepoConnectionsByIDScan scans the result of an executed CountRepoConnectionsByIDBatch query.
-	CountRepoConnectionsByIDScan(results pgx.BatchResults) (*int, error)
+	CountRepoConnectionsByIDScan(results pgx.BatchResults) (int, error)
 
 	DeleteWorkspaceConnectionByID(ctx context.Context, workspaceID pgtype.Text) (DeleteWorkspaceConnectionByIDRow, error)
 	// DeleteWorkspaceConnectionByIDBatch enqueues a DeleteWorkspaceConnectionByID query into batch to be executed
@@ -440,12 +440,12 @@ type Querier interface {
 	// FindRunsScan scans the result of an executed FindRunsBatch query.
 	FindRunsScan(results pgx.BatchResults) ([]FindRunsRow, error)
 
-	CountRuns(ctx context.Context, params CountRunsParams) (*int, error)
+	CountRuns(ctx context.Context, params CountRunsParams) (int, error)
 	// CountRunsBatch enqueues a CountRuns query into batch to be executed
 	// later by the batch.
 	CountRunsBatch(batch genericBatch, params CountRunsParams)
 	// CountRunsScan scans the result of an executed CountRunsBatch query.
-	CountRunsScan(results pgx.BatchResults) (*int, error)
+	CountRunsScan(results pgx.BatchResults) (int, error)
 
 	FindRunByID(ctx context.Context, runID pgtype.Text) (FindRunByIDRow, error)
 	// FindRunByIDBatch enqueues a FindRunByID query into batch to be executed
@@ -510,12 +510,12 @@ type Querier interface {
 	// FindStateVersionsByWorkspaceNameScan scans the result of an executed FindStateVersionsByWorkspaceNameBatch query.
 	FindStateVersionsByWorkspaceNameScan(results pgx.BatchResults) ([]FindStateVersionsByWorkspaceNameRow, error)
 
-	CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (*int, error)
+	CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (int, error)
 	// CountStateVersionsByWorkspaceNameBatch enqueues a CountStateVersionsByWorkspaceName query into batch to be executed
 	// later by the batch.
 	CountStateVersionsByWorkspaceNameBatch(batch genericBatch, workspaceName pgtype.Text, organizationName pgtype.Text)
 	// CountStateVersionsByWorkspaceNameScan scans the result of an executed CountStateVersionsByWorkspaceNameBatch query.
-	CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (*int, error)
+	CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (int, error)
 
 	FindStateVersionByID(ctx context.Context, id pgtype.Text) (FindStateVersionByIDRow, error)
 	// FindStateVersionByIDBatch enqueues a FindStateVersionByID query into batch to be executed
@@ -559,6 +559,83 @@ type Querier interface {
 	// FindStateVersionOutputByIDScan scans the result of an executed FindStateVersionOutputByIDBatch query.
 	FindStateVersionOutputByIDScan(results pgx.BatchResults) (FindStateVersionOutputByIDRow, error)
 
+	InsertTag(ctx context.Context, params InsertTagParams) (pgconn.CommandTag, error)
+	// InsertTagBatch enqueues a InsertTag query into batch to be executed
+	// later by the batch.
+	InsertTagBatch(batch genericBatch, params InsertTagParams)
+	// InsertTagScan scans the result of an executed InsertTagBatch query.
+	InsertTagScan(results pgx.BatchResults) (pgconn.CommandTag, error)
+
+	InsertWorkspaceTag(ctx context.Context, tagID pgtype.Text, workspaceID pgtype.Text) (pgtype.Text, error)
+	// InsertWorkspaceTagBatch enqueues a InsertWorkspaceTag query into batch to be executed
+	// later by the batch.
+	InsertWorkspaceTagBatch(batch genericBatch, tagID pgtype.Text, workspaceID pgtype.Text)
+	// InsertWorkspaceTagScan scans the result of an executed InsertWorkspaceTagBatch query.
+	InsertWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error)
+
+	InsertWorkspaceTagByName(ctx context.Context, workspaceID pgtype.Text, tagName pgtype.Text) (pgtype.Text, error)
+	// InsertWorkspaceTagByNameBatch enqueues a InsertWorkspaceTagByName query into batch to be executed
+	// later by the batch.
+	InsertWorkspaceTagByNameBatch(batch genericBatch, workspaceID pgtype.Text, tagName pgtype.Text)
+	// InsertWorkspaceTagByNameScan scans the result of an executed InsertWorkspaceTagByNameBatch query.
+	InsertWorkspaceTagByNameScan(results pgx.BatchResults) (pgtype.Text, error)
+
+	FindTags(ctx context.Context, params FindTagsParams) ([]FindTagsRow, error)
+	// FindTagsBatch enqueues a FindTags query into batch to be executed
+	// later by the batch.
+	FindTagsBatch(batch genericBatch, params FindTagsParams)
+	// FindTagsScan scans the result of an executed FindTagsBatch query.
+	FindTagsScan(results pgx.BatchResults) ([]FindTagsRow, error)
+
+	FindWorkspaceTags(ctx context.Context, params FindWorkspaceTagsParams) ([]FindWorkspaceTagsRow, error)
+	// FindWorkspaceTagsBatch enqueues a FindWorkspaceTags query into batch to be executed
+	// later by the batch.
+	FindWorkspaceTagsBatch(batch genericBatch, params FindWorkspaceTagsParams)
+	// FindWorkspaceTagsScan scans the result of an executed FindWorkspaceTagsBatch query.
+	FindWorkspaceTagsScan(results pgx.BatchResults) ([]FindWorkspaceTagsRow, error)
+
+	FindTagByName(ctx context.Context, name pgtype.Text, organizationName pgtype.Text) (FindTagByNameRow, error)
+	// FindTagByNameBatch enqueues a FindTagByName query into batch to be executed
+	// later by the batch.
+	FindTagByNameBatch(batch genericBatch, name pgtype.Text, organizationName pgtype.Text)
+	// FindTagByNameScan scans the result of an executed FindTagByNameBatch query.
+	FindTagByNameScan(results pgx.BatchResults) (FindTagByNameRow, error)
+
+	FindTagByID(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (FindTagByIDRow, error)
+	// FindTagByIDBatch enqueues a FindTagByID query into batch to be executed
+	// later by the batch.
+	FindTagByIDBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text)
+	// FindTagByIDScan scans the result of an executed FindTagByIDBatch query.
+	FindTagByIDScan(results pgx.BatchResults) (FindTagByIDRow, error)
+
+	CountTags(ctx context.Context, organizationName pgtype.Text) (int, error)
+	// CountTagsBatch enqueues a CountTags query into batch to be executed
+	// later by the batch.
+	CountTagsBatch(batch genericBatch, organizationName pgtype.Text)
+	// CountTagsScan scans the result of an executed CountTagsBatch query.
+	CountTagsScan(results pgx.BatchResults) (int, error)
+
+	CountWorkspaceTags(ctx context.Context, workspaceID pgtype.Text) (int, error)
+	// CountWorkspaceTagsBatch enqueues a CountWorkspaceTags query into batch to be executed
+	// later by the batch.
+	CountWorkspaceTagsBatch(batch genericBatch, workspaceID pgtype.Text)
+	// CountWorkspaceTagsScan scans the result of an executed CountWorkspaceTagsBatch query.
+	CountWorkspaceTagsScan(results pgx.BatchResults) (int, error)
+
+	DeleteTag(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (pgtype.Text, error)
+	// DeleteTagBatch enqueues a DeleteTag query into batch to be executed
+	// later by the batch.
+	DeleteTagBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text)
+	// DeleteTagScan scans the result of an executed DeleteTagBatch query.
+	DeleteTagScan(results pgx.BatchResults) (pgtype.Text, error)
+
+	DeleteWorkspaceTag(ctx context.Context, workspaceID pgtype.Text, tagID pgtype.Text) (pgtype.Text, error)
+	// DeleteWorkspaceTagBatch enqueues a DeleteWorkspaceTag query into batch to be executed
+	// later by the batch.
+	DeleteWorkspaceTagBatch(batch genericBatch, workspaceID pgtype.Text, tagID pgtype.Text)
+	// DeleteWorkspaceTagScan scans the result of an executed DeleteWorkspaceTagBatch query.
+	DeleteWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error)
+
 	InsertTeam(ctx context.Context, params InsertTeamParams) (pgconn.CommandTag, error)
 	// InsertTeamBatch enqueues a InsertTeam query into batch to be executed
 	// later by the batch.
@@ -846,12 +923,12 @@ type Querier interface {
 	// FindWorkspacesScan scans the result of an executed FindWorkspacesBatch query.
 	FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspacesRow, error)
 
-	CountWorkspaces(ctx context.Context, prefix pgtype.Text, organizationNames []string) (*int, error)
+	CountWorkspaces(ctx context.Context, params CountWorkspacesParams) (int, error)
 	// CountWorkspacesBatch enqueues a CountWorkspaces query into batch to be executed
 	// later by the batch.
-	CountWorkspacesBatch(batch genericBatch, prefix pgtype.Text, organizationNames []string)
+	CountWorkspacesBatch(batch genericBatch, params CountWorkspacesParams)
 	// CountWorkspacesScan scans the result of an executed CountWorkspacesBatch query.
-	CountWorkspacesScan(results pgx.BatchResults) (*int, error)
+	CountWorkspacesScan(results pgx.BatchResults) (int, error)
 
 	FindWorkspacesByWebhookID(ctx context.Context, webhookID pgtype.UUID) ([]FindWorkspacesByWebhookIDRow, error)
 	// FindWorkspacesByWebhookIDBatch enqueues a FindWorkspacesByWebhookID query into batch to be executed
@@ -867,12 +944,12 @@ type Querier interface {
 	// FindWorkspacesByUsernameScan scans the result of an executed FindWorkspacesByUsernameBatch query.
 	FindWorkspacesByUsernameScan(results pgx.BatchResults) ([]FindWorkspacesByUsernameRow, error)
 
-	CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (*int, error)
+	CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (int, error)
 	// CountWorkspacesByUsernameBatch enqueues a CountWorkspacesByUsername query into batch to be executed
 	// later by the batch.
 	CountWorkspacesByUsernameBatch(batch genericBatch, organizationName pgtype.Text, username pgtype.Text)
 	// CountWorkspacesByUsernameScan scans the result of an executed CountWorkspacesByUsernameBatch query.
-	CountWorkspacesByUsernameScan(results pgx.BatchResults) (*int, error)
+	CountWorkspacesByUsernameScan(results pgx.BatchResults) (int, error)
 
 	FindWorkspaceByName(ctx context.Context, name pgtype.Text, organizationName pgtype.Text) (FindWorkspaceByNameRow, error)
 	// FindWorkspaceByNameBatch enqueues a FindWorkspaceByName query into batch to be executed
@@ -937,12 +1014,12 @@ type Querier interface {
 	// UpsertWorkspacePermissionScan scans the result of an executed UpsertWorkspacePermissionBatch query.
 	UpsertWorkspacePermissionScan(results pgx.BatchResults) (pgconn.CommandTag, error)
 
-	FindWorkspacePermissions(ctx context.Context, workspaceID pgtype.Text) ([]FindWorkspacePermissionsRow, error)
-	// FindWorkspacePermissionsBatch enqueues a FindWorkspacePermissions query into batch to be executed
+	FindWorkspacePolicyByID(ctx context.Context, workspaceID pgtype.Text) (FindWorkspacePolicyByIDRow, error)
+	// FindWorkspacePolicyByIDBatch enqueues a FindWorkspacePolicyByID query into batch to be executed
 	// later by the batch.
-	FindWorkspacePermissionsBatch(batch genericBatch, workspaceID pgtype.Text)
-	// FindWorkspacePermissionsScan scans the result of an executed FindWorkspacePermissionsBatch query.
-	FindWorkspacePermissionsScan(results pgx.BatchResults) ([]FindWorkspacePermissionsRow, error)
+	FindWorkspacePolicyByIDBatch(batch genericBatch, workspaceID pgtype.Text)
+	// FindWorkspacePolicyByIDScan scans the result of an executed FindWorkspacePolicyByIDBatch query.
+	FindWorkspacePolicyByIDScan(results pgx.BatchResults) (FindWorkspacePolicyByIDRow, error)
 
 	DeleteWorkspacePermissionByID(ctx context.Context, workspaceID pgtype.Text, teamName pgtype.Text) (pgconn.CommandTag, error)
 	// DeleteWorkspacePermissionByIDBatch enqueues a DeleteWorkspacePermissionByID query into batch to be executed
@@ -1255,6 +1332,39 @@ func PrepareAllQueries(ctx context.Context, p preparer) error {
 	if _, err := p.Prepare(ctx, findStateVersionOutputByIDSQL, findStateVersionOutputByIDSQL); err != nil {
 		return fmt.Errorf("prepare query 'FindStateVersionOutputByID': %w", err)
 	}
+	if _, err := p.Prepare(ctx, insertTagSQL, insertTagSQL); err != nil {
+		return fmt.Errorf("prepare query 'InsertTag': %w", err)
+	}
+	if _, err := p.Prepare(ctx, insertWorkspaceTagSQL, insertWorkspaceTagSQL); err != nil {
+		return fmt.Errorf("prepare query 'InsertWorkspaceTag': %w", err)
+	}
+	if _, err := p.Prepare(ctx, insertWorkspaceTagByNameSQL, insertWorkspaceTagByNameSQL); err != nil {
+		return fmt.Errorf("prepare query 'InsertWorkspaceTagByName': %w", err)
+	}
+	if _, err := p.Prepare(ctx, findTagsSQL, findTagsSQL); err != nil {
+		return fmt.Errorf("prepare query 'FindTags': %w", err)
+	}
+	if _, err := p.Prepare(ctx, findWorkspaceTagsSQL, findWorkspaceTagsSQL); err != nil {
+		return fmt.Errorf("prepare query 'FindWorkspaceTags': %w", err)
+	}
+	if _, err := p.Prepare(ctx, findTagByNameSQL, findTagByNameSQL); err != nil {
+		return fmt.Errorf("prepare query 'FindTagByName': %w", err)
+	}
+	if _, err := p.Prepare(ctx, findTagByIDSQL, findTagByIDSQL); err != nil {
+		return fmt.Errorf("prepare query 'FindTagByID': %w", err)
+	}
+	if _, err := p.Prepare(ctx, countTagsSQL, countTagsSQL); err != nil {
+		return fmt.Errorf("prepare query 'CountTags': %w", err)
+	}
+	if _, err := p.Prepare(ctx, countWorkspaceTagsSQL, countWorkspaceTagsSQL); err != nil {
+		return fmt.Errorf("prepare query 'CountWorkspaceTags': %w", err)
+	}
+	if _, err := p.Prepare(ctx, deleteTagSQL, deleteTagSQL); err != nil {
+		return fmt.Errorf("prepare query 'DeleteTag': %w", err)
+	}
+	if _, err := p.Prepare(ctx, deleteWorkspaceTagSQL, deleteWorkspaceTagSQL); err != nil {
+		return fmt.Errorf("prepare query 'DeleteWorkspaceTag': %w", err)
+	}
 	if _, err := p.Prepare(ctx, insertTeamSQL, insertTeamSQL); err != nil {
 		return fmt.Errorf("prepare query 'InsertTeam': %w", err)
 	}
@@ -1417,8 +1527,8 @@ func PrepareAllQueries(ctx context.Context, p preparer) error {
 	if _, err := p.Prepare(ctx, upsertWorkspacePermissionSQL, upsertWorkspacePermissionSQL); err != nil {
 		return fmt.Errorf("prepare query 'UpsertWorkspacePermission': %w", err)
 	}
-	if _, err := p.Prepare(ctx, findWorkspacePermissionsSQL, findWorkspacePermissionsSQL); err != nil {
-		return fmt.Errorf("prepare query 'FindWorkspacePermissions': %w", err)
+	if _, err := p.Prepare(ctx, findWorkspacePolicyByIDSQL, findWorkspacePolicyByIDSQL); err != nil {
+		return fmt.Errorf("prepare query 'FindWorkspacePolicyByID': %w", err)
 	}
 	if _, err := p.Prepare(ctx, deleteWorkspacePermissionByIDSQL, deleteWorkspacePermissionByIDSQL); err != nil {
 		return fmt.Errorf("prepare query 'DeleteWorkspacePermissionByID': %w", err)
@@ -1541,6 +1651,13 @@ type Webhooks struct {
 	Cloud      pgtype.Text `json:"cloud"`
 }
 
+// WorkspacePermissions represents the Postgres composite type "workspace_permissions".
+type WorkspacePermissions struct {
+	WorkspaceID pgtype.Text `json:"workspace_id"`
+	TeamID      pgtype.Text `json:"team_id"`
+	Role        pgtype.Text `json:"role"`
+}
+
 // typeResolver looks up the pgtype.ValueTranscoder by Postgres type name.
 type typeResolver struct {
 	connInfo *pgtype.ConnInfo // types by Postgres type name
@@ -1790,6 +1907,17 @@ func (tr *typeResolver) newWebhooks() pgtype.ValueTranscoder {
 	)
 }
 
+// newWorkspacePermissions creates a new pgtype.ValueTranscoder for the Postgres
+// composite type 'workspace_permissions'.
+func (tr *typeResolver) newWorkspacePermissions() pgtype.ValueTranscoder {
+	return tr.newCompositeValue(
+		"workspace_permissions",
+		compositeField{"workspace_id", "text", &pgtype.Text{}},
+		compositeField{"team_id", "text", &pgtype.Text{}},
+		compositeField{"role", "text", &pgtype.Text{}},
+	)
+}
+
 // newConfigurationVersionStatusTimestampsArray creates a new pgtype.ValueTranscoder for the Postgres
 // '_configuration_version_status_timestamps' array type.
 func (tr *typeResolver) newConfigurationVersionStatusTimestampsArray() pgtype.ValueTranscoder {
@@ -1826,6 +1954,12 @@ func (tr *typeResolver) newTeamsArray() pgtype.ValueTranscoder {
 	return tr.newArrayValue("_teams", "teams", tr.newTeams)
 }
 
+// newWorkspacePermissionsArray creates a new pgtype.ValueTranscoder for the Postgres
+// '_workspace_permissions' array type.
+func (tr *typeResolver) newWorkspacePermissionsArray() pgtype.ValueTranscoder {
+	return tr.newArrayValue("_workspace_permissions", "workspace_permissions", tr.newWorkspacePermissions)
+}
+
 const insertAgentTokenSQL = `INSERT INTO agent_tokens (
     token_id,
     created_at,
diff --git a/sql/pggen/configuration_version.sql.go b/sql/pggen/configuration_version.sql.go
index 919409d2b..906102b8b 100644
--- a/sql/pggen/configuration_version.sql.go
+++ b/sql/pggen/configuration_version.sql.go
@@ -222,14 +222,14 @@ WHERE configuration_versions.workspace_id = $1
 ;`
 
 // CountConfigurationVersionsByWorkspaceID implements Querier.CountConfigurationVersionsByWorkspaceID.
-func (q *DBQuerier) CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (*int, error) {
+func (q *DBQuerier) CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (int, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "CountConfigurationVersionsByWorkspaceID")
 	row := q.conn.QueryRow(ctx, countConfigurationVersionsByWorkspaceIDSQL, workspaceID)
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("query CountConfigurationVersionsByWorkspaceID: %w", err)
+		return item, fmt.Errorf("query CountConfigurationVersionsByWorkspaceID: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 // CountConfigurationVersionsByWorkspaceIDBatch implements Querier.CountConfigurationVersionsByWorkspaceIDBatch.
@@ -238,13 +238,13 @@ func (q *DBQuerier) CountConfigurationVersionsByWorkspaceIDBatch(batch genericBa
 }
 
 // CountConfigurationVersionsByWorkspaceIDScan implements Querier.CountConfigurationVersionsByWorkspaceIDScan.
-func (q *DBQuerier) CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (*int, error) {
+func (q *DBQuerier) CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (int, error) {
 	row := results.QueryRow()
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("scan CountConfigurationVersionsByWorkspaceIDBatch row: %w", err)
+		return item, fmt.Errorf("scan CountConfigurationVersionsByWorkspaceIDBatch row: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 const findConfigurationVersionByIDSQL = `SELECT
diff --git a/sql/pggen/organization.sql.go b/sql/pggen/organization.sql.go
index 42756e9e6..4958a14e6 100644
--- a/sql/pggen/organization.sql.go
+++ b/sql/pggen/organization.sql.go
@@ -229,18 +229,18 @@ func (q *DBQuerier) FindOrganizationsScan(results pgx.BatchResults) ([]FindOrgan
 
 const countOrganizationsSQL = `SELECT count(*)
 FROM organizations
-WHERE name = ANY($1)
+WHERE name LIKE ANY($1)
 ;`
 
 // CountOrganizations implements Querier.CountOrganizations.
-func (q *DBQuerier) CountOrganizations(ctx context.Context, names []string) (*int, error) {
+func (q *DBQuerier) CountOrganizations(ctx context.Context, names []string) (int, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "CountOrganizations")
 	row := q.conn.QueryRow(ctx, countOrganizationsSQL, names)
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("query CountOrganizations: %w", err)
+		return item, fmt.Errorf("query CountOrganizations: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 // CountOrganizationsBatch implements Querier.CountOrganizationsBatch.
@@ -249,13 +249,13 @@ func (q *DBQuerier) CountOrganizationsBatch(batch genericBatch, names []string)
 }
 
 // CountOrganizationsScan implements Querier.CountOrganizationsScan.
-func (q *DBQuerier) CountOrganizationsScan(results pgx.BatchResults) (*int, error) {
+func (q *DBQuerier) CountOrganizationsScan(results pgx.BatchResults) (int, error) {
 	row := results.QueryRow()
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("scan CountOrganizationsBatch row: %w", err)
+		return item, fmt.Errorf("scan CountOrganizationsBatch row: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 const insertOrganizationSQL = `INSERT INTO organizations (
diff --git a/sql/pggen/repo_connections.sql.go b/sql/pggen/repo_connections.sql.go
index 5f50a1394..458da5cb9 100644
--- a/sql/pggen/repo_connections.sql.go
+++ b/sql/pggen/repo_connections.sql.go
@@ -60,14 +60,14 @@ WHERE webhook_id = $1
 ;`
 
 // CountRepoConnectionsByID implements Querier.CountRepoConnectionsByID.
-func (q *DBQuerier) CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (*int, error) {
+func (q *DBQuerier) CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (int, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "CountRepoConnectionsByID")
 	row := q.conn.QueryRow(ctx, countRepoConnectionsByIDSQL, webhookID)
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("query CountRepoConnectionsByID: %w", err)
+		return item, fmt.Errorf("query CountRepoConnectionsByID: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 // CountRepoConnectionsByIDBatch implements Querier.CountRepoConnectionsByIDBatch.
@@ -76,13 +76,13 @@ func (q *DBQuerier) CountRepoConnectionsByIDBatch(batch genericBatch, webhookID
 }
 
 // CountRepoConnectionsByIDScan implements Querier.CountRepoConnectionsByIDScan.
-func (q *DBQuerier) CountRepoConnectionsByIDScan(results pgx.BatchResults) (*int, error) {
+func (q *DBQuerier) CountRepoConnectionsByIDScan(results pgx.BatchResults) (int, error) {
 	row := results.QueryRow()
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("scan CountRepoConnectionsByIDBatch row: %w", err)
+		return item, fmt.Errorf("scan CountRepoConnectionsByIDBatch row: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 const deleteWorkspaceConnectionByIDSQL = `DELETE
diff --git a/sql/pggen/run.sql.go b/sql/pggen/run.sql.go
index 420442ed0..977411d3d 100644
--- a/sql/pggen/run.sql.go
+++ b/sql/pggen/run.sql.go
@@ -333,14 +333,14 @@ type CountRunsParams struct {
 }
 
 // CountRuns implements Querier.CountRuns.
-func (q *DBQuerier) CountRuns(ctx context.Context, params CountRunsParams) (*int, error) {
+func (q *DBQuerier) CountRuns(ctx context.Context, params CountRunsParams) (int, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "CountRuns")
 	row := q.conn.QueryRow(ctx, countRunsSQL, params.OrganizationNames, params.WorkspaceIds, params.WorkspaceNames, params.Statuses, params.Speculative)
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("query CountRuns: %w", err)
+		return item, fmt.Errorf("query CountRuns: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 // CountRunsBatch implements Querier.CountRunsBatch.
@@ -349,13 +349,13 @@ func (q *DBQuerier) CountRunsBatch(batch genericBatch, params CountRunsParams) {
 }
 
 // CountRunsScan implements Querier.CountRunsScan.
-func (q *DBQuerier) CountRunsScan(results pgx.BatchResults) (*int, error) {
+func (q *DBQuerier) CountRunsScan(results pgx.BatchResults) (int, error) {
 	row := results.QueryRow()
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("scan CountRunsBatch row: %w", err)
+		return item, fmt.Errorf("scan CountRunsBatch row: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 const findRunByIDSQL = `SELECT
diff --git a/sql/pggen/state_version.sql.go b/sql/pggen/state_version.sql.go
index 60a46e331..c77d88320 100644
--- a/sql/pggen/state_version.sql.go
+++ b/sql/pggen/state_version.sql.go
@@ -151,14 +151,14 @@ AND   workspaces.organization_name    = $2
 ;`
 
 // CountStateVersionsByWorkspaceName implements Querier.CountStateVersionsByWorkspaceName.
-func (q *DBQuerier) CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (*int, error) {
+func (q *DBQuerier) CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (int, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "CountStateVersionsByWorkspaceName")
 	row := q.conn.QueryRow(ctx, countStateVersionsByWorkspaceNameSQL, workspaceName, organizationName)
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("query CountStateVersionsByWorkspaceName: %w", err)
+		return item, fmt.Errorf("query CountStateVersionsByWorkspaceName: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 // CountStateVersionsByWorkspaceNameBatch implements Querier.CountStateVersionsByWorkspaceNameBatch.
@@ -167,13 +167,13 @@ func (q *DBQuerier) CountStateVersionsByWorkspaceNameBatch(batch genericBatch, w
 }
 
 // CountStateVersionsByWorkspaceNameScan implements Querier.CountStateVersionsByWorkspaceNameScan.
-func (q *DBQuerier) CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (*int, error) {
+func (q *DBQuerier) CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (int, error) {
 	row := results.QueryRow()
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("scan CountStateVersionsByWorkspaceNameBatch row: %w", err)
+		return item, fmt.Errorf("scan CountStateVersionsByWorkspaceNameBatch row: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 const findStateVersionByIDSQL = `SELECT
diff --git a/sql/pggen/tags.sql.go b/sql/pggen/tags.sql.go
new file mode 100644
index 000000000..4cec60826
--- /dev/null
+++ b/sql/pggen/tags.sql.go
@@ -0,0 +1,493 @@
+// Code generated by pggen. DO NOT EDIT.
+
+package pggen
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgconn"
+	"github.com/jackc/pgtype"
+	"github.com/jackc/pgx/v4"
+)
+
+const insertTagSQL = `INSERT INTO tags (
+    tag_id,
+    name,
+    organization_name
+) VALUES (
+    $1,
+    $2,
+    $3
+) ON CONFLICT (organization_name, name) DO NOTHING
+;`
+
+type InsertTagParams struct {
+	TagID            pgtype.Text
+	Name             pgtype.Text
+	OrganizationName pgtype.Text
+}
+
+// InsertTag implements Querier.InsertTag.
+func (q *DBQuerier) InsertTag(ctx context.Context, params InsertTagParams) (pgconn.CommandTag, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "InsertTag")
+	cmdTag, err := q.conn.Exec(ctx, insertTagSQL, params.TagID, params.Name, params.OrganizationName)
+	if err != nil {
+		return cmdTag, fmt.Errorf("exec query InsertTag: %w", err)
+	}
+	return cmdTag, err
+}
+
+// InsertTagBatch implements Querier.InsertTagBatch.
+func (q *DBQuerier) InsertTagBatch(batch genericBatch, params InsertTagParams) {
+	batch.Queue(insertTagSQL, params.TagID, params.Name, params.OrganizationName)
+}
+
+// InsertTagScan implements Querier.InsertTagScan.
+func (q *DBQuerier) InsertTagScan(results pgx.BatchResults) (pgconn.CommandTag, error) {
+	cmdTag, err := results.Exec()
+	if err != nil {
+		return cmdTag, fmt.Errorf("exec InsertTagBatch: %w", err)
+	}
+	return cmdTag, err
+}
+
+const insertWorkspaceTagSQL = `INSERT INTO workspace_tags (
+    tag_id,
+    workspace_id
+) SELECT $1, $2
+  FROM workspaces w
+  JOIN tags t ON (t.organization_name = w.organization_name)
+  WHERE w.workspace_id = $2
+  AND t.tag_id = $1
+RETURNING tag_id
+;`
+
+// InsertWorkspaceTag implements Querier.InsertWorkspaceTag.
+func (q *DBQuerier) InsertWorkspaceTag(ctx context.Context, tagID pgtype.Text, workspaceID pgtype.Text) (pgtype.Text, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "InsertWorkspaceTag")
+	row := q.conn.QueryRow(ctx, insertWorkspaceTagSQL, tagID, workspaceID)
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("query InsertWorkspaceTag: %w", err)
+	}
+	return item, nil
+}
+
+// InsertWorkspaceTagBatch implements Querier.InsertWorkspaceTagBatch.
+func (q *DBQuerier) InsertWorkspaceTagBatch(batch genericBatch, tagID pgtype.Text, workspaceID pgtype.Text) {
+	batch.Queue(insertWorkspaceTagSQL, tagID, workspaceID)
+}
+
+// InsertWorkspaceTagScan implements Querier.InsertWorkspaceTagScan.
+func (q *DBQuerier) InsertWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error) {
+	row := results.QueryRow()
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("scan InsertWorkspaceTagBatch row: %w", err)
+	}
+	return item, nil
+}
+
+const insertWorkspaceTagByNameSQL = `INSERT INTO workspace_tags (
+    tag_id,
+    workspace_id
+) SELECT t.tag_id, $1
+  FROM workspaces w
+  JOIN tags t ON (t.organization_name = w.organization_name)
+  WHERE t.name = $2
+RETURNING tag_id
+;`
+
+// InsertWorkspaceTagByName implements Querier.InsertWorkspaceTagByName.
+func (q *DBQuerier) InsertWorkspaceTagByName(ctx context.Context, workspaceID pgtype.Text, tagName pgtype.Text) (pgtype.Text, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "InsertWorkspaceTagByName")
+	row := q.conn.QueryRow(ctx, insertWorkspaceTagByNameSQL, workspaceID, tagName)
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("query InsertWorkspaceTagByName: %w", err)
+	}
+	return item, nil
+}
+
+// InsertWorkspaceTagByNameBatch implements Querier.InsertWorkspaceTagByNameBatch.
+func (q *DBQuerier) InsertWorkspaceTagByNameBatch(batch genericBatch, workspaceID pgtype.Text, tagName pgtype.Text) {
+	batch.Queue(insertWorkspaceTagByNameSQL, workspaceID, tagName)
+}
+
+// InsertWorkspaceTagByNameScan implements Querier.InsertWorkspaceTagByNameScan.
+func (q *DBQuerier) InsertWorkspaceTagByNameScan(results pgx.BatchResults) (pgtype.Text, error) {
+	row := results.QueryRow()
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("scan InsertWorkspaceTagByNameBatch row: %w", err)
+	}
+	return item, nil
+}
+
+const findTagsSQL = `SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM tags t
+WHERE t.organization_name = $1
+LIMIT $2
+OFFSET $3
+;`
+
+type FindTagsParams struct {
+	OrganizationName pgtype.Text
+	Limit            int
+	Offset           int
+}
+
+type FindTagsRow struct {
+	TagID            pgtype.Text `json:"tag_id"`
+	Name             pgtype.Text `json:"name"`
+	OrganizationName pgtype.Text `json:"organization_name"`
+	InstanceCount    int         `json:"instance_count"`
+}
+
+// FindTags implements Querier.FindTags.
+func (q *DBQuerier) FindTags(ctx context.Context, params FindTagsParams) ([]FindTagsRow, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "FindTags")
+	rows, err := q.conn.Query(ctx, findTagsSQL, params.OrganizationName, params.Limit, params.Offset)
+	if err != nil {
+		return nil, fmt.Errorf("query FindTags: %w", err)
+	}
+	defer rows.Close()
+	items := []FindTagsRow{}
+	for rows.Next() {
+		var item FindTagsRow
+		if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+			return nil, fmt.Errorf("scan FindTags row: %w", err)
+		}
+		items = append(items, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("close FindTags rows: %w", err)
+	}
+	return items, err
+}
+
+// FindTagsBatch implements Querier.FindTagsBatch.
+func (q *DBQuerier) FindTagsBatch(batch genericBatch, params FindTagsParams) {
+	batch.Queue(findTagsSQL, params.OrganizationName, params.Limit, params.Offset)
+}
+
+// FindTagsScan implements Querier.FindTagsScan.
+func (q *DBQuerier) FindTagsScan(results pgx.BatchResults) ([]FindTagsRow, error) {
+	rows, err := results.Query()
+	if err != nil {
+		return nil, fmt.Errorf("query FindTagsBatch: %w", err)
+	}
+	defer rows.Close()
+	items := []FindTagsRow{}
+	for rows.Next() {
+		var item FindTagsRow
+		if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+			return nil, fmt.Errorf("scan FindTagsBatch row: %w", err)
+		}
+		items = append(items, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("close FindTagsBatch rows: %w", err)
+	}
+	return items, err
+}
+
+const findWorkspaceTagsSQL = `SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM workspace_tags wt
+JOIN tags t USING (tag_id)
+WHERE wt.workspace_id = $1
+LIMIT $2
+OFFSET $3
+;`
+
+type FindWorkspaceTagsParams struct {
+	WorkspaceID pgtype.Text
+	Limit       int
+	Offset      int
+}
+
+type FindWorkspaceTagsRow struct {
+	TagID            pgtype.Text `json:"tag_id"`
+	Name             pgtype.Text `json:"name"`
+	OrganizationName pgtype.Text `json:"organization_name"`
+	InstanceCount    int         `json:"instance_count"`
+}
+
+// FindWorkspaceTags implements Querier.FindWorkspaceTags.
+func (q *DBQuerier) FindWorkspaceTags(ctx context.Context, params FindWorkspaceTagsParams) ([]FindWorkspaceTagsRow, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspaceTags")
+	rows, err := q.conn.Query(ctx, findWorkspaceTagsSQL, params.WorkspaceID, params.Limit, params.Offset)
+	if err != nil {
+		return nil, fmt.Errorf("query FindWorkspaceTags: %w", err)
+	}
+	defer rows.Close()
+	items := []FindWorkspaceTagsRow{}
+	for rows.Next() {
+		var item FindWorkspaceTagsRow
+		if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+			return nil, fmt.Errorf("scan FindWorkspaceTags row: %w", err)
+		}
+		items = append(items, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("close FindWorkspaceTags rows: %w", err)
+	}
+	return items, err
+}
+
+// FindWorkspaceTagsBatch implements Querier.FindWorkspaceTagsBatch.
+func (q *DBQuerier) FindWorkspaceTagsBatch(batch genericBatch, params FindWorkspaceTagsParams) {
+	batch.Queue(findWorkspaceTagsSQL, params.WorkspaceID, params.Limit, params.Offset)
+}
+
+// FindWorkspaceTagsScan implements Querier.FindWorkspaceTagsScan.
+func (q *DBQuerier) FindWorkspaceTagsScan(results pgx.BatchResults) ([]FindWorkspaceTagsRow, error) {
+	rows, err := results.Query()
+	if err != nil {
+		return nil, fmt.Errorf("query FindWorkspaceTagsBatch: %w", err)
+	}
+	defer rows.Close()
+	items := []FindWorkspaceTagsRow{}
+	for rows.Next() {
+		var item FindWorkspaceTagsRow
+		if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+			return nil, fmt.Errorf("scan FindWorkspaceTagsBatch row: %w", err)
+		}
+		items = append(items, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("close FindWorkspaceTagsBatch rows: %w", err)
+	}
+	return items, err
+}
+
+const findTagByNameSQL = `SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM tags t
+WHERE t.name = $1
+AND   t.organization_name = $2
+;`
+
+type FindTagByNameRow struct {
+	TagID            pgtype.Text `json:"tag_id"`
+	Name             pgtype.Text `json:"name"`
+	OrganizationName pgtype.Text `json:"organization_name"`
+	InstanceCount    int         `json:"instance_count"`
+}
+
+// FindTagByName implements Querier.FindTagByName.
+func (q *DBQuerier) FindTagByName(ctx context.Context, name pgtype.Text, organizationName pgtype.Text) (FindTagByNameRow, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "FindTagByName")
+	row := q.conn.QueryRow(ctx, findTagByNameSQL, name, organizationName)
+	var item FindTagByNameRow
+	if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+		return item, fmt.Errorf("query FindTagByName: %w", err)
+	}
+	return item, nil
+}
+
+// FindTagByNameBatch implements Querier.FindTagByNameBatch.
+func (q *DBQuerier) FindTagByNameBatch(batch genericBatch, name pgtype.Text, organizationName pgtype.Text) {
+	batch.Queue(findTagByNameSQL, name, organizationName)
+}
+
+// FindTagByNameScan implements Querier.FindTagByNameScan.
+func (q *DBQuerier) FindTagByNameScan(results pgx.BatchResults) (FindTagByNameRow, error) {
+	row := results.QueryRow()
+	var item FindTagByNameRow
+	if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+		return item, fmt.Errorf("scan FindTagByNameBatch row: %w", err)
+	}
+	return item, nil
+}
+
+const findTagByIDSQL = `SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM tags t
+WHERE t.tag_id = $1
+AND   t.organization_name = $2
+;`
+
+type FindTagByIDRow struct {
+	TagID            pgtype.Text `json:"tag_id"`
+	Name             pgtype.Text `json:"name"`
+	OrganizationName pgtype.Text `json:"organization_name"`
+	InstanceCount    int         `json:"instance_count"`
+}
+
+// FindTagByID implements Querier.FindTagByID.
+func (q *DBQuerier) FindTagByID(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (FindTagByIDRow, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "FindTagByID")
+	row := q.conn.QueryRow(ctx, findTagByIDSQL, tagID, organizationName)
+	var item FindTagByIDRow
+	if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+		return item, fmt.Errorf("query FindTagByID: %w", err)
+	}
+	return item, nil
+}
+
+// FindTagByIDBatch implements Querier.FindTagByIDBatch.
+func (q *DBQuerier) FindTagByIDBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text) {
+	batch.Queue(findTagByIDSQL, tagID, organizationName)
+}
+
+// FindTagByIDScan implements Querier.FindTagByIDScan.
+func (q *DBQuerier) FindTagByIDScan(results pgx.BatchResults) (FindTagByIDRow, error) {
+	row := results.QueryRow()
+	var item FindTagByIDRow
+	if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil {
+		return item, fmt.Errorf("scan FindTagByIDBatch row: %w", err)
+	}
+	return item, nil
+}
+
+const countTagsSQL = `SELECT count(*)
+FROM tags t
+WHERE t.organization_name = $1
+;`
+
+// CountTags implements Querier.CountTags.
+func (q *DBQuerier) CountTags(ctx context.Context, organizationName pgtype.Text) (int, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "CountTags")
+	row := q.conn.QueryRow(ctx, countTagsSQL, organizationName)
+	var item int
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("query CountTags: %w", err)
+	}
+	return item, nil
+}
+
+// CountTagsBatch implements Querier.CountTagsBatch.
+func (q *DBQuerier) CountTagsBatch(batch genericBatch, organizationName pgtype.Text) {
+	batch.Queue(countTagsSQL, organizationName)
+}
+
+// CountTagsScan implements Querier.CountTagsScan.
+func (q *DBQuerier) CountTagsScan(results pgx.BatchResults) (int, error) {
+	row := results.QueryRow()
+	var item int
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("scan CountTagsBatch row: %w", err)
+	}
+	return item, nil
+}
+
+const countWorkspaceTagsSQL = `SELECT count(*)
+FROM workspace_tags wt
+WHERE wt.workspace_id = $1
+;`
+
+// CountWorkspaceTags implements Querier.CountWorkspaceTags.
+func (q *DBQuerier) CountWorkspaceTags(ctx context.Context, workspaceID pgtype.Text) (int, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "CountWorkspaceTags")
+	row := q.conn.QueryRow(ctx, countWorkspaceTagsSQL, workspaceID)
+	var item int
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("query CountWorkspaceTags: %w", err)
+	}
+	return item, nil
+}
+
+// CountWorkspaceTagsBatch implements Querier.CountWorkspaceTagsBatch.
+func (q *DBQuerier) CountWorkspaceTagsBatch(batch genericBatch, workspaceID pgtype.Text) {
+	batch.Queue(countWorkspaceTagsSQL, workspaceID)
+}
+
+// CountWorkspaceTagsScan implements Querier.CountWorkspaceTagsScan.
+func (q *DBQuerier) CountWorkspaceTagsScan(results pgx.BatchResults) (int, error) {
+	row := results.QueryRow()
+	var item int
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("scan CountWorkspaceTagsBatch row: %w", err)
+	}
+	return item, nil
+}
+
+const deleteTagSQL = `DELETE
+FROM tags
+WHERE tag_id            = $1
+AND   organization_name = $2
+RETURNING tag_id
+;`
+
+// DeleteTag implements Querier.DeleteTag.
+func (q *DBQuerier) DeleteTag(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (pgtype.Text, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "DeleteTag")
+	row := q.conn.QueryRow(ctx, deleteTagSQL, tagID, organizationName)
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("query DeleteTag: %w", err)
+	}
+	return item, nil
+}
+
+// DeleteTagBatch implements Querier.DeleteTagBatch.
+func (q *DBQuerier) DeleteTagBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text) {
+	batch.Queue(deleteTagSQL, tagID, organizationName)
+}
+
+// DeleteTagScan implements Querier.DeleteTagScan.
+func (q *DBQuerier) DeleteTagScan(results pgx.BatchResults) (pgtype.Text, error) {
+	row := results.QueryRow()
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("scan DeleteTagBatch row: %w", err)
+	}
+	return item, nil
+}
+
+const deleteWorkspaceTagSQL = `DELETE
+FROM workspace_tags
+WHERE workspace_id  = $1
+AND   tag_id        = $2
+RETURNING tag_id
+;`
+
+// DeleteWorkspaceTag implements Querier.DeleteWorkspaceTag.
+func (q *DBQuerier) DeleteWorkspaceTag(ctx context.Context, workspaceID pgtype.Text, tagID pgtype.Text) (pgtype.Text, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "DeleteWorkspaceTag")
+	row := q.conn.QueryRow(ctx, deleteWorkspaceTagSQL, workspaceID, tagID)
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("query DeleteWorkspaceTag: %w", err)
+	}
+	return item, nil
+}
+
+// DeleteWorkspaceTagBatch implements Querier.DeleteWorkspaceTagBatch.
+func (q *DBQuerier) DeleteWorkspaceTagBatch(batch genericBatch, workspaceID pgtype.Text, tagID pgtype.Text) {
+	batch.Queue(deleteWorkspaceTagSQL, workspaceID, tagID)
+}
+
+// DeleteWorkspaceTagScan implements Querier.DeleteWorkspaceTagScan.
+func (q *DBQuerier) DeleteWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error) {
+	row := results.QueryRow()
+	var item pgtype.Text
+	if err := row.Scan(&item); err != nil {
+		return item, fmt.Errorf("scan DeleteWorkspaceTagBatch row: %w", err)
+	}
+	return item, nil
+}
diff --git a/sql/pggen/workspace.sql.go b/sql/pggen/workspace.sql.go
index 8e5992f6b..78f9d967f 100644
--- a/sql/pggen/workspace.sql.go
+++ b/sql/pggen/workspace.sql.go
@@ -113,26 +113,50 @@ func (q *DBQuerier) InsertWorkspaceScan(results pgx.BatchResults) (pgconn.Comman
 
 const findWorkspacesSQL = `SELECT
     w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
-    (ul.*)::"users" AS user_lock,
-    (rl.*)::"runs" AS run_lock,
-    (vr.*)::"repo_connections" AS workspace_connection,
-    (h.*)::"webhooks" AS webhook
+    (
+        SELECT (u.*)::"users"
+        FROM users u
+        WHERE u.username = w.lock_username
+    ) AS user_lock,
+    (
+        SELECT (rl.*)::"runs"
+        FROM runs rl
+        WHERE rl.run_id = w.lock_run_id
+    ) AS run_lock,
+    (
+        SELECT (rc.*)::"repo_connections"
+        FROM repo_connections rc
+        WHERE rc.workspace_id = w.workspace_id
+    ) AS workspace_connection,
+    (
+        SELECT (wh.*)::"webhooks"
+        FROM webhooks wh
+        JOIN repo_connections rc USING (webhook_id)
+        WHERE rc.workspace_id = w.workspace_id
+    ) AS webhook
 FROM workspaces w
-LEFT JOIN users ul ON w.lock_username = ul.username
-LEFT JOIN runs rl ON w.lock_run_id = rl.run_id
 LEFT JOIN runs r ON w.latest_run_id = r.run_id
-LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id
+LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON wt.workspace_id = w.workspace_id
 WHERE w.name                LIKE $1 || '%'
 AND   w.organization_name   LIKE ANY($2)
+GROUP BY w.workspace_id, r.status
+HAVING array_agg(t.name) @> $3
 ORDER BY w.updated_at DESC
-LIMIT $3
-OFFSET $4
+LIMIT $4
+OFFSET $5
 ;`
 
 type FindWorkspacesParams struct {
 	Prefix            pgtype.Text
 	OrganizationNames []string
+	Tags              []string
 	Limit             int
 	Offset            int
 }
@@ -165,6 +189,7 @@ type FindWorkspacesRow struct {
 	Branch                     pgtype.Text        `json:"branch"`
 	LockUsername               pgtype.Text        `json:"lock_username"`
 	CurrentStateVersionID      pgtype.Text        `json:"current_state_version_id"`
+	Tags                       []string           `json:"tags"`
 	LatestRunStatus            pgtype.Text        `json:"latest_run_status"`
 	UserLock                   *Users             `json:"user_lock"`
 	RunLock                    *Runs              `json:"run_lock"`
@@ -175,7 +200,7 @@ type FindWorkspacesRow struct {
 // FindWorkspaces implements Querier.FindWorkspaces.
 func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesParams) ([]FindWorkspacesRow, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspaces")
-	rows, err := q.conn.Query(ctx, findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Limit, params.Offset)
+	rows, err := q.conn.Query(ctx, findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags, params.Limit, params.Offset)
 	if err != nil {
 		return nil, fmt.Errorf("query FindWorkspaces: %w", err)
 	}
@@ -187,7 +212,7 @@ func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesPar
 	webhookRow := q.types.newWebhooks()
 	for rows.Next() {
 		var item FindWorkspacesRow
-		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 			return nil, fmt.Errorf("scan FindWorkspaces row: %w", err)
 		}
 		if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -212,7 +237,7 @@ func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesPar
 
 // FindWorkspacesBatch implements Querier.FindWorkspacesBatch.
 func (q *DBQuerier) FindWorkspacesBatch(batch genericBatch, params FindWorkspacesParams) {
-	batch.Queue(findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Limit, params.Offset)
+	batch.Queue(findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags, params.Limit, params.Offset)
 }
 
 // FindWorkspacesScan implements Querier.FindWorkspacesScan.
@@ -229,7 +254,7 @@ func (q *DBQuerier) FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspac
 	webhookRow := q.types.newWebhooks()
 	for rows.Next() {
 		var item FindWorkspacesRow
-		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 			return nil, fmt.Errorf("scan FindWorkspacesBatch row: %w", err)
 		}
 		if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -253,39 +278,55 @@ func (q *DBQuerier) FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspac
 }
 
 const countWorkspacesSQL = `SELECT count(*)
-FROM workspaces
-WHERE name LIKE $1 || '%'
-AND   organization_name LIKE ANY($2)
+FROM workspaces w
+LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON w.workspace_id = wt.workspace_id
+WHERE w.name              LIKE $1 || '%'
+AND   w.organization_name LIKE ANY($2)
+AND   CASE WHEN cardinality($3::text[]) > 0 THEN t.name LIKE ANY($3)
+      ELSE 1 = 1
+      END
 ;`
 
+type CountWorkspacesParams struct {
+	Prefix            pgtype.Text
+	OrganizationNames []string
+	Tags              []string
+}
+
 // CountWorkspaces implements Querier.CountWorkspaces.
-func (q *DBQuerier) CountWorkspaces(ctx context.Context, prefix pgtype.Text, organizationNames []string) (*int, error) {
+func (q *DBQuerier) CountWorkspaces(ctx context.Context, params CountWorkspacesParams) (int, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "CountWorkspaces")
-	row := q.conn.QueryRow(ctx, countWorkspacesSQL, prefix, organizationNames)
+	row := q.conn.QueryRow(ctx, countWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags)
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("query CountWorkspaces: %w", err)
+		return item, fmt.Errorf("query CountWorkspaces: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 // CountWorkspacesBatch implements Querier.CountWorkspacesBatch.
-func (q *DBQuerier) CountWorkspacesBatch(batch genericBatch, prefix pgtype.Text, organizationNames []string) {
-	batch.Queue(countWorkspacesSQL, prefix, organizationNames)
+func (q *DBQuerier) CountWorkspacesBatch(batch genericBatch, params CountWorkspacesParams) {
+	batch.Queue(countWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags)
 }
 
 // CountWorkspacesScan implements Querier.CountWorkspacesScan.
-func (q *DBQuerier) CountWorkspacesScan(results pgx.BatchResults) (*int, error) {
+func (q *DBQuerier) CountWorkspacesScan(results pgx.BatchResults) (int, error) {
 	row := results.QueryRow()
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("scan CountWorkspacesBatch row: %w", err)
+		return item, fmt.Errorf("scan CountWorkspacesBatch row: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 const findWorkspacesByWebhookIDSQL = `SELECT
     w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -327,6 +368,7 @@ type FindWorkspacesByWebhookIDRow struct {
 	Branch                     pgtype.Text        `json:"branch"`
 	LockUsername               pgtype.Text        `json:"lock_username"`
 	CurrentStateVersionID      pgtype.Text        `json:"current_state_version_id"`
+	Tags                       []string           `json:"tags"`
 	LatestRunStatus            pgtype.Text        `json:"latest_run_status"`
 	UserLock                   *Users             `json:"user_lock"`
 	RunLock                    *Runs              `json:"run_lock"`
@@ -349,7 +391,7 @@ func (q *DBQuerier) FindWorkspacesByWebhookID(ctx context.Context, webhookID pgt
 	webhookRow := q.types.newWebhooks()
 	for rows.Next() {
 		var item FindWorkspacesByWebhookIDRow
-		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 			return nil, fmt.Errorf("scan FindWorkspacesByWebhookID row: %w", err)
 		}
 		if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -391,7 +433,7 @@ func (q *DBQuerier) FindWorkspacesByWebhookIDScan(results pgx.BatchResults) ([]F
 	webhookRow := q.types.newWebhooks()
 	for rows.Next() {
 		var item FindWorkspacesByWebhookIDRow
-		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 			return nil, fmt.Errorf("scan FindWorkspacesByWebhookIDBatch row: %w", err)
 		}
 		if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -416,6 +458,12 @@ func (q *DBQuerier) FindWorkspacesByWebhookIDScan(results pgx.BatchResults) ([]F
 
 const findWorkspacesByUsernameSQL = `SELECT
     w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -472,6 +520,7 @@ type FindWorkspacesByUsernameRow struct {
 	Branch                     pgtype.Text        `json:"branch"`
 	LockUsername               pgtype.Text        `json:"lock_username"`
 	CurrentStateVersionID      pgtype.Text        `json:"current_state_version_id"`
+	Tags                       []string           `json:"tags"`
 	LatestRunStatus            pgtype.Text        `json:"latest_run_status"`
 	UserLock                   *Users             `json:"user_lock"`
 	RunLock                    *Runs              `json:"run_lock"`
@@ -494,7 +543,7 @@ func (q *DBQuerier) FindWorkspacesByUsername(ctx context.Context, params FindWor
 	webhookRow := q.types.newWebhooks()
 	for rows.Next() {
 		var item FindWorkspacesByUsernameRow
-		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 			return nil, fmt.Errorf("scan FindWorkspacesByUsername row: %w", err)
 		}
 		if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -536,7 +585,7 @@ func (q *DBQuerier) FindWorkspacesByUsernameScan(results pgx.BatchResults) ([]Fi
 	webhookRow := q.types.newWebhooks()
 	for rows.Next() {
 		var item FindWorkspacesByUsernameRow
-		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+		if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 			return nil, fmt.Errorf("scan FindWorkspacesByUsernameBatch row: %w", err)
 		}
 		if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -570,14 +619,14 @@ AND   u.username          = $2
 ;`
 
 // CountWorkspacesByUsername implements Querier.CountWorkspacesByUsername.
-func (q *DBQuerier) CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (*int, error) {
+func (q *DBQuerier) CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (int, error) {
 	ctx = context.WithValue(ctx, "pggen_query_name", "CountWorkspacesByUsername")
 	row := q.conn.QueryRow(ctx, countWorkspacesByUsernameSQL, organizationName, username)
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("query CountWorkspacesByUsername: %w", err)
+		return item, fmt.Errorf("query CountWorkspacesByUsername: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 // CountWorkspacesByUsernameBatch implements Querier.CountWorkspacesByUsernameBatch.
@@ -586,16 +635,22 @@ func (q *DBQuerier) CountWorkspacesByUsernameBatch(batch genericBatch, organizat
 }
 
 // CountWorkspacesByUsernameScan implements Querier.CountWorkspacesByUsernameScan.
-func (q *DBQuerier) CountWorkspacesByUsernameScan(results pgx.BatchResults) (*int, error) {
+func (q *DBQuerier) CountWorkspacesByUsernameScan(results pgx.BatchResults) (int, error) {
 	row := results.QueryRow()
 	var item int
 	if err := row.Scan(&item); err != nil {
-		return &item, fmt.Errorf("scan CountWorkspacesByUsernameBatch row: %w", err)
+		return item, fmt.Errorf("scan CountWorkspacesByUsernameBatch row: %w", err)
 	}
-	return &item, nil
+	return item, nil
 }
 
 const findWorkspaceByNameSQL = `SELECT w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -638,6 +693,7 @@ type FindWorkspaceByNameRow struct {
 	Branch                     pgtype.Text        `json:"branch"`
 	LockUsername               pgtype.Text        `json:"lock_username"`
 	CurrentStateVersionID      pgtype.Text        `json:"current_state_version_id"`
+	Tags                       []string           `json:"tags"`
 	LatestRunStatus            pgtype.Text        `json:"latest_run_status"`
 	UserLock                   *Users             `json:"user_lock"`
 	RunLock                    *Runs              `json:"run_lock"`
@@ -654,7 +710,7 @@ func (q *DBQuerier) FindWorkspaceByName(ctx context.Context, name pgtype.Text, o
 	runLockRow := q.types.newRuns()
 	workspaceConnectionRow := q.types.newRepoConnections()
 	webhookRow := q.types.newWebhooks()
-	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 		return item, fmt.Errorf("query FindWorkspaceByName: %w", err)
 	}
 	if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -685,7 +741,7 @@ func (q *DBQuerier) FindWorkspaceByNameScan(results pgx.BatchResults) (FindWorks
 	runLockRow := q.types.newRuns()
 	workspaceConnectionRow := q.types.newRepoConnections()
 	webhookRow := q.types.newWebhooks()
-	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 		return item, fmt.Errorf("scan FindWorkspaceByNameBatch row: %w", err)
 	}
 	if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -704,6 +760,12 @@ func (q *DBQuerier) FindWorkspaceByNameScan(results pgx.BatchResults) (FindWorks
 }
 
 const findWorkspaceByIDSQL = `SELECT w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -745,6 +807,7 @@ type FindWorkspaceByIDRow struct {
 	Branch                     pgtype.Text        `json:"branch"`
 	LockUsername               pgtype.Text        `json:"lock_username"`
 	CurrentStateVersionID      pgtype.Text        `json:"current_state_version_id"`
+	Tags                       []string           `json:"tags"`
 	LatestRunStatus            pgtype.Text        `json:"latest_run_status"`
 	UserLock                   *Users             `json:"user_lock"`
 	RunLock                    *Runs              `json:"run_lock"`
@@ -761,7 +824,7 @@ func (q *DBQuerier) FindWorkspaceByID(ctx context.Context, id pgtype.Text) (Find
 	runLockRow := q.types.newRuns()
 	workspaceConnectionRow := q.types.newRepoConnections()
 	webhookRow := q.types.newWebhooks()
-	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 		return item, fmt.Errorf("query FindWorkspaceByID: %w", err)
 	}
 	if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -792,7 +855,7 @@ func (q *DBQuerier) FindWorkspaceByIDScan(results pgx.BatchResults) (FindWorkspa
 	runLockRow := q.types.newRuns()
 	workspaceConnectionRow := q.types.newRepoConnections()
 	webhookRow := q.types.newWebhooks()
-	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 		return item, fmt.Errorf("scan FindWorkspaceByIDBatch row: %w", err)
 	}
 	if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -811,6 +874,12 @@ func (q *DBQuerier) FindWorkspaceByIDScan(results pgx.BatchResults) (FindWorkspa
 }
 
 const findWorkspaceByIDForUpdateSQL = `SELECT w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -852,6 +921,7 @@ type FindWorkspaceByIDForUpdateRow struct {
 	Branch                     pgtype.Text        `json:"branch"`
 	LockUsername               pgtype.Text        `json:"lock_username"`
 	CurrentStateVersionID      pgtype.Text        `json:"current_state_version_id"`
+	Tags                       []string           `json:"tags"`
 	LatestRunStatus            pgtype.Text        `json:"latest_run_status"`
 	UserLock                   *Users             `json:"user_lock"`
 	RunLock                    *Runs              `json:"run_lock"`
@@ -868,7 +938,7 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdate(ctx context.Context, id pgtype.Te
 	runLockRow := q.types.newRuns()
 	workspaceConnectionRow := q.types.newRepoConnections()
 	webhookRow := q.types.newWebhooks()
-	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 		return item, fmt.Errorf("query FindWorkspaceByIDForUpdate: %w", err)
 	}
 	if err := userLockRow.AssignTo(&item.UserLock); err != nil {
@@ -899,7 +969,7 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdateScan(results pgx.BatchResults) (Fi
 	runLockRow := q.types.newRuns()
 	workspaceConnectionRow := q.types.newRepoConnections()
 	webhookRow := q.types.newWebhooks()
-	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
+	if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil {
 		return item, fmt.Errorf("scan FindWorkspaceByIDForUpdateBatch row: %w", err)
 	}
 	if err := userLockRow.AssignTo(&item.UserLock); err != nil {
diff --git a/sql/pggen/workspace_permission.sql.go b/sql/pggen/workspace_permission.sql.go
index 446436938..c15eed64f 100644
--- a/sql/pggen/workspace_permission.sql.go
+++ b/sql/pggen/workspace_permission.sql.go
@@ -54,66 +54,71 @@ func (q *DBQuerier) UpsertWorkspacePermissionScan(results pgx.BatchResults) (pgc
 	return cmdTag, err
 }
 
-const findWorkspacePermissionsSQL = `SELECT
-    wp.role,
-    t.name AS team_name
-FROM workspace_permissions wp
-JOIN teams t USING (team_id)
-JOIN workspaces w USING (workspace_id)
-WHERE wp.workspace_id = $1
+const findWorkspacePolicyByIDSQL = `SELECT
+    w.organization_name,
+    w.workspace_id,
+    (
+        SELECT array_remove(array_agg(t.*), NULL)
+        FROM teams t
+        JOIN workspace_permissions wp USING (team_id)
+        WHERE wp.workspace_id = w.workspace_id
+    ) AS teams,
+    (
+        SELECT array_remove(array_agg(wp.*), NULL)
+        FROM workspace_permissions wp
+        WHERE wp.workspace_id = w.workspace_id
+    ) AS workspace_permissions
+FROM workspaces w
+WHERE workspace_id = $1
 ;`
 
-type FindWorkspacePermissionsRow struct {
-	Role     pgtype.Text `json:"role"`
-	TeamName pgtype.Text `json:"team_name"`
+type FindWorkspacePolicyByIDRow struct {
+	OrganizationName     pgtype.Text            `json:"organization_name"`
+	WorkspaceID          pgtype.Text            `json:"workspace_id"`
+	Teams                []Teams                `json:"teams"`
+	WorkspacePermissions []WorkspacePermissions `json:"workspace_permissions"`
 }
 
-// FindWorkspacePermissions implements Querier.FindWorkspacePermissions.
-func (q *DBQuerier) FindWorkspacePermissions(ctx context.Context, workspaceID pgtype.Text) ([]FindWorkspacePermissionsRow, error) {
-	ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspacePermissions")
-	rows, err := q.conn.Query(ctx, findWorkspacePermissionsSQL, workspaceID)
-	if err != nil {
-		return nil, fmt.Errorf("query FindWorkspacePermissions: %w", err)
+// FindWorkspacePolicyByID implements Querier.FindWorkspacePolicyByID.
+func (q *DBQuerier) FindWorkspacePolicyByID(ctx context.Context, workspaceID pgtype.Text) (FindWorkspacePolicyByIDRow, error) {
+	ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspacePolicyByID")
+	row := q.conn.QueryRow(ctx, findWorkspacePolicyByIDSQL, workspaceID)
+	var item FindWorkspacePolicyByIDRow
+	teamsArray := q.types.newTeamsArray()
+	workspacePermissionsArray := q.types.newWorkspacePermissionsArray()
+	if err := row.Scan(&item.OrganizationName, &item.WorkspaceID, teamsArray, workspacePermissionsArray); err != nil {
+		return item, fmt.Errorf("query FindWorkspacePolicyByID: %w", err)
 	}
-	defer rows.Close()
-	items := []FindWorkspacePermissionsRow{}
-	for rows.Next() {
-		var item FindWorkspacePermissionsRow
-		if err := rows.Scan(&item.Role, &item.TeamName); err != nil {
-			return nil, fmt.Errorf("scan FindWorkspacePermissions row: %w", err)
-		}
-		items = append(items, item)
+	if err := teamsArray.AssignTo(&item.Teams); err != nil {
+		return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err)
 	}
-	if err := rows.Err(); err != nil {
-		return nil, fmt.Errorf("close FindWorkspacePermissions rows: %w", err)
+	if err := workspacePermissionsArray.AssignTo(&item.WorkspacePermissions); err != nil {
+		return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err)
 	}
-	return items, err
+	return item, nil
 }
 
-// FindWorkspacePermissionsBatch implements Querier.FindWorkspacePermissionsBatch.
-func (q *DBQuerier) FindWorkspacePermissionsBatch(batch genericBatch, workspaceID pgtype.Text) {
-	batch.Queue(findWorkspacePermissionsSQL, workspaceID)
+// FindWorkspacePolicyByIDBatch implements Querier.FindWorkspacePolicyByIDBatch.
+func (q *DBQuerier) FindWorkspacePolicyByIDBatch(batch genericBatch, workspaceID pgtype.Text) {
+	batch.Queue(findWorkspacePolicyByIDSQL, workspaceID)
 }
 
-// FindWorkspacePermissionsScan implements Querier.FindWorkspacePermissionsScan.
-func (q *DBQuerier) FindWorkspacePermissionsScan(results pgx.BatchResults) ([]FindWorkspacePermissionsRow, error) {
-	rows, err := results.Query()
-	if err != nil {
-		return nil, fmt.Errorf("query FindWorkspacePermissionsBatch: %w", err)
+// FindWorkspacePolicyByIDScan implements Querier.FindWorkspacePolicyByIDScan.
+func (q *DBQuerier) FindWorkspacePolicyByIDScan(results pgx.BatchResults) (FindWorkspacePolicyByIDRow, error) {
+	row := results.QueryRow()
+	var item FindWorkspacePolicyByIDRow
+	teamsArray := q.types.newTeamsArray()
+	workspacePermissionsArray := q.types.newWorkspacePermissionsArray()
+	if err := row.Scan(&item.OrganizationName, &item.WorkspaceID, teamsArray, workspacePermissionsArray); err != nil {
+		return item, fmt.Errorf("scan FindWorkspacePolicyByIDBatch row: %w", err)
 	}
-	defer rows.Close()
-	items := []FindWorkspacePermissionsRow{}
-	for rows.Next() {
-		var item FindWorkspacePermissionsRow
-		if err := rows.Scan(&item.Role, &item.TeamName); err != nil {
-			return nil, fmt.Errorf("scan FindWorkspacePermissionsBatch row: %w", err)
-		}
-		items = append(items, item)
+	if err := teamsArray.AssignTo(&item.Teams); err != nil {
+		return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err)
 	}
-	if err := rows.Err(); err != nil {
-		return nil, fmt.Errorf("close FindWorkspacePermissionsBatch rows: %w", err)
+	if err := workspacePermissionsArray.AssignTo(&item.WorkspacePermissions); err != nil {
+		return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err)
 	}
-	return items, err
+	return item, nil
 }
 
 const deleteWorkspacePermissionByIDSQL = `DELETE
diff --git a/sql/queries/organization.sql b/sql/queries/organization.sql
index 582a83d78..b90be9da0 100644
--- a/sql/queries/organization.sql
+++ b/sql/queries/organization.sql
@@ -28,7 +28,7 @@ LIMIT pggen.arg('limit') OFFSET pggen.arg('offset')
 -- name: CountOrganizations :one
 SELECT count(*)
 FROM organizations
-WHERE name = ANY(pggen.arg('names'))
+WHERE name LIKE ANY(pggen.arg('names'))
 ;
 
 -- name: InsertOrganization :exec
diff --git a/sql/queries/tags.sql b/sql/queries/tags.sql
new file mode 100644
index 000000000..2f5134ba7
--- /dev/null
+++ b/sql/queries/tags.sql
@@ -0,0 +1,117 @@
+-- name: InsertTag :exec
+INSERT INTO tags (
+    tag_id,
+    name,
+    organization_name
+) VALUES (
+    pggen.arg('tag_id'),
+    pggen.arg('name'),
+    pggen.arg('organization_name')
+) ON CONFLICT (organization_name, name) DO NOTHING
+;
+
+-- name: InsertWorkspaceTag :one
+INSERT INTO workspace_tags (
+    tag_id,
+    workspace_id
+) SELECT pggen.arg('tag_id'), pggen.arg('workspace_id')
+  FROM workspaces w
+  JOIN tags t ON (t.organization_name = w.organization_name)
+  WHERE w.workspace_id = pggen.arg('workspace_id')
+  AND t.tag_id = pggen.arg('tag_id')
+RETURNING tag_id
+;
+
+-- name: InsertWorkspaceTagByName :one
+INSERT INTO workspace_tags (
+    tag_id,
+    workspace_id
+) SELECT t.tag_id, pggen.arg('workspace_id')
+  FROM workspaces w
+  JOIN tags t ON (t.organization_name = w.organization_name)
+  WHERE t.name = pggen.arg('tag_name')
+RETURNING tag_id
+;
+
+-- name: FindTags :many
+SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM tags t
+WHERE t.organization_name = pggen.arg('organization_name')
+LIMIT pggen.arg('limit')
+OFFSET pggen.arg('offset')
+;
+
+-- name: FindWorkspaceTags :many
+SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM workspace_tags wt
+JOIN tags t USING (tag_id)
+WHERE wt.workspace_id = pggen.arg('workspace_id')
+LIMIT pggen.arg('limit')
+OFFSET pggen.arg('offset')
+;
+
+-- name: FindTagByName :one
+SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM tags t
+WHERE t.name = pggen.arg('name')
+AND   t.organization_name = pggen.arg('organization_name')
+;
+
+-- name: FindTagByID :one
+SELECT
+    t.*,
+    (
+        SELECT count(*)
+        FROM workspace_tags wt
+        WHERE wt.tag_id = t.tag_id
+    ) AS instance_count
+FROM tags t
+WHERE t.tag_id = pggen.arg('tag_id')
+AND   t.organization_name = pggen.arg('organization_name')
+;
+
+-- name: CountTags :one
+SELECT count(*)
+FROM tags t
+WHERE t.organization_name = pggen.arg('organization_name')
+;
+
+-- name: CountWorkspaceTags :one
+SELECT count(*)
+FROM workspace_tags wt
+WHERE wt.workspace_id = pggen.arg('workspace_id')
+;
+
+-- name: DeleteTag :one
+DELETE
+FROM tags
+WHERE tag_id            = pggen.arg('tag_id')
+AND   organization_name = pggen.arg('organization_name')
+RETURNING tag_id
+;
+
+-- name: DeleteWorkspaceTag :one
+DELETE
+FROM workspace_tags
+WHERE workspace_id  = pggen.arg('workspace_id')
+AND   tag_id        = pggen.arg('tag_id')
+RETURNING tag_id
+;
diff --git a/sql/queries/workspace.sql b/sql/queries/workspace.sql
index 575356190..a5f7a8504 100644
--- a/sql/queries/workspace.sql
+++ b/sql/queries/workspace.sql
@@ -52,18 +52,41 @@ INSERT INTO workspaces (
 -- name: FindWorkspaces :many
 SELECT
     w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
-    (ul.*)::"users" AS user_lock,
-    (rl.*)::"runs" AS run_lock,
-    (vr.*)::"repo_connections" AS workspace_connection,
-    (h.*)::"webhooks" AS webhook
+    (
+        SELECT (u.*)::"users"
+        FROM users u
+        WHERE u.username = w.lock_username
+    ) AS user_lock,
+    (
+        SELECT (rl.*)::"runs"
+        FROM runs rl
+        WHERE rl.run_id = w.lock_run_id
+    ) AS run_lock,
+    (
+        SELECT (rc.*)::"repo_connections"
+        FROM repo_connections rc
+        WHERE rc.workspace_id = w.workspace_id
+    ) AS workspace_connection,
+    (
+        SELECT (wh.*)::"webhooks"
+        FROM webhooks wh
+        JOIN repo_connections rc USING (webhook_id)
+        WHERE rc.workspace_id = w.workspace_id
+    ) AS webhook
 FROM workspaces w
-LEFT JOIN users ul ON w.lock_username = ul.username
-LEFT JOIN runs rl ON w.lock_run_id = rl.run_id
 LEFT JOIN runs r ON w.latest_run_id = r.run_id
-LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id
+LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON wt.workspace_id = w.workspace_id
 WHERE w.name                LIKE pggen.arg('prefix') || '%'
 AND   w.organization_name   LIKE ANY(pggen.arg('organization_names'))
+GROUP BY w.workspace_id, r.status
+HAVING array_agg(t.name) @> pggen.arg('tags')
 ORDER BY w.updated_at DESC
 LIMIT pggen.arg('limit')
 OFFSET pggen.arg('offset')
@@ -71,14 +94,24 @@ OFFSET pggen.arg('offset')
 
 -- name: CountWorkspaces :one
 SELECT count(*)
-FROM workspaces
-WHERE name LIKE pggen.arg('prefix') || '%'
-AND   organization_name LIKE ANY(pggen.arg('organization_names'))
+FROM workspaces w
+LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON w.workspace_id = wt.workspace_id
+WHERE w.name              LIKE pggen.arg('prefix') || '%'
+AND   w.organization_name LIKE ANY(pggen.arg('organization_names'))
+AND   CASE WHEN cardinality(pggen.arg('tags')::text[]) > 0 THEN t.name LIKE ANY(pggen.arg('tags'))
+      ELSE 1 = 1
+      END
 ;
 
 -- name: FindWorkspacesByWebhookID :many
 SELECT
     w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -95,6 +128,12 @@ WHERE h.webhook_id = pggen.arg('webhook_id')
 -- name: FindWorkspacesByUsername :many
 SELECT
     w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -129,6 +168,12 @@ AND   u.username          = pggen.arg('username')
 
 -- name: FindWorkspaceByName :one
 SELECT w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -145,6 +190,12 @@ AND   w.organization_name = pggen.arg('organization_name')
 
 -- name: FindWorkspaceByID :one
 SELECT w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
@@ -160,6 +211,12 @@ WHERE w.workspace_id = pggen.arg('id')
 
 -- name: FindWorkspaceByIDForUpdate :one
 SELECT w.*,
+    (
+        SELECT array_agg(name)
+        FROM tags
+        JOIN workspace_tags wt USING (tag_id)
+        WHERE wt.workspace_id = w.workspace_id
+    ) AS tags,
     r.status AS latest_run_status,
     (ul.*)::"users" AS user_lock,
     (rl.*)::"runs" AS run_lock,
diff --git a/sql/queries/workspace_permission.sql b/sql/queries/workspace_permission.sql
index a05dabd0e..65f1bb37d 100644
--- a/sql/queries/workspace_permission.sql
+++ b/sql/queries/workspace_permission.sql
@@ -12,14 +12,23 @@ INSERT INTO workspace_permissions (
 ON CONFLICT (workspace_id, team_id) DO UPDATE SET role = pggen.arg('role')
 ;
 
--- name: FindWorkspacePermissions :many
+-- name: FindWorkspacePolicyByID :one
 SELECT
-    wp.role,
-    t.name AS team_name
-FROM workspace_permissions wp
-JOIN teams t USING (team_id)
-JOIN workspaces w USING (workspace_id)
-WHERE wp.workspace_id = pggen.arg('workspace_id')
+    w.organization_name,
+    w.workspace_id,
+    (
+        SELECT array_remove(array_agg(t.*), NULL)
+        FROM teams t
+        JOIN workspace_permissions wp USING (team_id)
+        WHERE wp.workspace_id = w.workspace_id
+    ) AS teams,
+    (
+        SELECT array_remove(array_agg(wp.*), NULL)
+        FROM workspace_permissions wp
+        WHERE wp.workspace_id = w.workspace_id
+    ) AS workspace_permissions
+FROM workspaces w
+WHERE workspace_id = pggen.arg('workspace_id')
 ;
 
 -- name: DeleteWorkspacePermissionByID :exec
diff --git a/state/db.go b/state/db.go
index b7002f67f..8efcb806c 100644
--- a/state/db.go
+++ b/state/db.go
@@ -102,7 +102,7 @@ func (db *pgdb) listVersions(ctx context.Context, opts StateVersionListOptions)
 
 	return &VersionList{
 		Items:      items,
-		Pagination: otf.NewPagination(opts.ListOptions, *count),
+		Pagination: otf.NewPagination(opts.ListOptions, count),
 	}, nil
 }
 
diff --git a/variable/service.go b/variable/service.go
index 3d2506ccf..1726604b3 100644
--- a/variable/service.go
+++ b/variable/service.go
@@ -7,7 +7,6 @@ import (
 	"github.com/gorilla/mux"
 	"github.com/leg100/otf"
 	"github.com/leg100/otf/http/html"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/rbac"
 	"github.com/leg100/otf/workspace"
 	"github.com/pkg/errors"
@@ -41,7 +40,6 @@ type (
 		otf.DB
 		html.Renderer
 		logr.Logger
-		policy.PolicyService
 	}
 )
 
@@ -53,10 +51,9 @@ func NewService(opts Options) *service {
 	}
 
 	svc.web = &web{
-		Renderer:      opts.Renderer,
-		Service:       opts.WorkspaceService,
-		PolicyService: opts.PolicyService,
-		svc:           &svc,
+		Renderer: opts.Renderer,
+		Service:  opts.WorkspaceService,
+		svc:      &svc,
 	}
 
 	return &svc
diff --git a/variable/web.go b/variable/web.go
index 162c8e272..42d13c533 100644
--- a/variable/web.go
+++ b/variable/web.go
@@ -8,7 +8,6 @@ import (
 	"github.com/leg100/otf/http/decode"
 	"github.com/leg100/otf/http/html"
 	"github.com/leg100/otf/http/html/paths"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/rbac"
 	"github.com/leg100/otf/workspace"
 )
@@ -16,7 +15,6 @@ import (
 type web struct {
 	html.Renderer
 	workspace.Service
-	policy.PolicyService
 
 	svc Service
 }
diff --git a/workspace/authorizer.go b/workspace/authorizer.go
new file mode 100644
index 000000000..a3b8075a0
--- /dev/null
+++ b/workspace/authorizer.go
@@ -0,0 +1,32 @@
+package workspace
+
+import (
+	"context"
+
+	"github.com/go-logr/logr"
+	"github.com/leg100/otf"
+	"github.com/leg100/otf/rbac"
+)
+
+// authorizer authorizes access to a workspace
+type authorizer struct {
+	logr.Logger
+
+	db *pgdb
+}
+
+func (a *authorizer) CanAccess(ctx context.Context, action rbac.Action, workspaceID string) (otf.Subject, error) {
+	subj, err := otf.SubjectFromContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+	policy, err := a.db.GetWorkspacePolicy(ctx, workspaceID)
+	if err != nil {
+		return nil, otf.ErrResourceNotFound
+	}
+	if subj.CanAccessWorkspace(action, policy) {
+		return subj, nil
+	}
+	a.Error(nil, "unauthorized action", "workspace", workspaceID, "organization", policy.Organization, "action", action, "subject", subj)
+	return nil, otf.ErrAccessNotPermitted
+}
diff --git a/workspace/db.go b/workspace/db.go
index 220ccf869..8cb3d03b3 100644
--- a/workspace/db.go
+++ b/workspace/db.go
@@ -13,7 +13,7 @@ import (
 )
 
 type (
-	// pgdb is a state/state-version database on postgres
+	// pgdb is a workspace database on postgres
 	pgdb struct {
 		otf.DB // provides access to generated SQL queries
 	}
@@ -47,6 +47,7 @@ type (
 		Branch                     pgtype.Text            `json:"branch"`
 		LockUsername               pgtype.Text            `json:"lock_username"`
 		CurrentStateVersionID      pgtype.Text            `json:"current_state_version_id"`
+		Tags                       []string               `json:"tags"`
 		LatestRunStatus            pgtype.Text            `json:"latest_run_status"`
 		UserLock                   *pggen.Users           `json:"user_lock"`
 		RunLock                    *pggen.Runs            `json:"run_lock"`
@@ -80,6 +81,7 @@ func (r pgresult) toWorkspace() (*Workspace, error) {
 		TriggerPrefixes:            r.TriggerPrefixes,
 		WorkingDirectory:           r.WorkingDirectory.String,
 		Organization:               r.OrganizationName.String,
+		Tags:                       r.Tags,
 	}
 
 	if r.WorkspaceConnection != nil {
@@ -193,20 +195,27 @@ func (db *pgdb) list(ctx context.Context, opts ListOptions) (*WorkspaceList, err
 
 	// Organization name filter is optional - if not provided use a % which in
 	// SQL means match any organization.
-	var organizationName string
+	organization := "%"
 	if opts.Organization != nil {
-		organizationName = *opts.Organization
-	} else {
-		organizationName = "%"
+		organization = *opts.Organization
+	}
+	tags := []string{}
+	if len(opts.Tags) > 0 {
+		tags = opts.Tags
 	}
 
 	db.FindWorkspacesBatch(batch, pggen.FindWorkspacesParams{
-		OrganizationNames: []string{organizationName},
+		OrganizationNames: []string{organization},
 		Prefix:            sql.String(opts.Prefix),
+		Tags:              tags,
 		Limit:             opts.GetLimit(),
 		Offset:            opts.GetOffset(),
 	})
-	db.CountWorkspacesBatch(batch, sql.String(opts.Prefix), []string{organizationName})
+	db.CountWorkspacesBatch(batch, pggen.CountWorkspacesParams{
+		Prefix:            sql.String(opts.Prefix),
+		OrganizationNames: []string{organization},
+		Tags:              tags,
+	})
 	results := db.SendBatch(ctx, batch)
 	defer results.Close()
 
@@ -230,7 +239,7 @@ func (db *pgdb) list(ctx context.Context, opts ListOptions) (*WorkspaceList, err
 
 	return &WorkspaceList{
 		Items:      items,
-		Pagination: otf.NewPagination(opts.ListOptions, *count),
+		Pagination: otf.NewPagination(opts.ListOptions, count),
 	}, nil
 }
 
@@ -285,7 +294,7 @@ func (db *pgdb) listByUsername(ctx context.Context, username string, organizatio
 
 	return &WorkspaceList{
 		Items:      items,
-		Pagination: otf.NewPagination(opts, *count),
+		Pagination: otf.NewPagination(opts, count),
 	}, nil
 }
 
diff --git a/workspace/lock_service.go b/workspace/lock_service.go
index a8c985f33..aecd5c56f 100644
--- a/workspace/lock_service.go
+++ b/workspace/lock_service.go
@@ -26,7 +26,7 @@ func (s *service) LockWorkspace(ctx context.Context, workspaceID string, runID *
 		id = *runID
 		kind = RunLock
 	} else {
-		subject, err := s.workspace.CanAccess(ctx, rbac.LockWorkspaceAction, workspaceID)
+		subject, err := s.CanAccess(ctx, rbac.LockWorkspaceAction, workspaceID)
 		if err != nil {
 			return nil, err
 		}
@@ -70,7 +70,7 @@ func (s *service) UnlockWorkspace(ctx context.Context, workspaceID string, runID
 		} else {
 			action = rbac.UnlockWorkspaceAction
 		}
-		subject, err := s.workspace.CanAccess(ctx, action, workspaceID)
+		subject, err := s.CanAccess(ctx, action, workspaceID)
 		if err != nil {
 			return nil, err
 		}
diff --git a/policy/db.go b/workspace/permissions_db.go
similarity index 50%
rename from policy/db.go
rename to workspace/permissions_db.go
index cfde77298..7140a9ad5 100644
--- a/policy/db.go
+++ b/workspace/permissions_db.go
@@ -1,4 +1,4 @@
-package policy
+package workspace
 
 import (
 	"context"
@@ -9,11 +9,7 @@ import (
 	"github.com/leg100/otf/sql/pggen"
 )
 
-type pgdb struct {
-	otf.DB
-}
-
-func (db *pgdb) setWorkspacePermission(ctx context.Context, workspaceID, team string, role rbac.Role) error {
+func (db *pgdb) SetWorkspacePermission(ctx context.Context, workspaceID, team string, role rbac.Role) error {
 	_, err := db.UpsertWorkspacePermission(ctx, pggen.UpsertWorkspacePermissionParams{
 		WorkspaceID: sql.String(workspaceID),
 		TeamName:    sql.String(team),
@@ -25,33 +21,38 @@ func (db *pgdb) setWorkspacePermission(ctx context.Context, workspaceID, team st
 	return nil
 }
 
-func (db *pgdb) getWorkspacePolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) {
-	ws, err := db.FindWorkspaceByID(ctx, sql.String(workspaceID))
-	if err != nil {
-		return otf.WorkspacePolicy{}, sql.Error(err)
-	}
-	rows, err := db.FindWorkspacePermissions(ctx, sql.String(workspaceID))
+func (db *pgdb) GetWorkspacePolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) {
+	result, err := db.FindWorkspacePolicyByID(ctx, sql.String(workspaceID))
 	if err != nil {
 		return otf.WorkspacePolicy{}, sql.Error(err)
 	}
 	policy := otf.WorkspacePolicy{
-		Organization: ws.OrganizationName.String,
-		WorkspaceID:  ws.WorkspaceID.String,
+		Organization: result.OrganizationName.String,
+		WorkspaceID:  result.WorkspaceID.String,
 	}
-	for _, perm := range rows {
+	// SQL query returns an array of workspace permissions and an array of
+	// teams; the former has the team id, but we need the team name, so
+	// lookup the corresponding team name in the array of teams.
+	for _, perm := range result.WorkspacePermissions {
 		role, err := rbac.WorkspaceRoleFromString(perm.Role.String)
 		if err != nil {
 			return otf.WorkspacePolicy{}, err
 		}
-		policy.Permissions = append(policy.Permissions, otf.WorkspacePermission{
-			Team: perm.TeamName.String,
-			Role: role,
-		})
+		// find corresponding team name in teams array
+		for _, t := range result.Teams {
+			if t.TeamID == perm.TeamID {
+				policy.Permissions = append(policy.Permissions, otf.WorkspacePermission{
+					Team: t.Name.String,
+					Role: role,
+				})
+				break
+			}
+		}
 	}
 	return policy, nil
 }
 
-func (db *pgdb) unsetWorkspacePermission(ctx context.Context, workspaceID, team string) error {
+func (db *pgdb) UnsetWorkspacePermission(ctx context.Context, workspaceID, team string) error {
 	_, err := db.DeleteWorkspacePermissionByID(ctx, sql.String(workspaceID), sql.String(team))
 	if err != nil {
 		return sql.Error(err)
diff --git a/workspace/permissions_service.go b/workspace/permissions_service.go
new file mode 100644
index 000000000..6d0bee2c9
--- /dev/null
+++ b/workspace/permissions_service.go
@@ -0,0 +1,53 @@
+package workspace
+
+import (
+	"context"
+
+	"github.com/leg100/otf"
+	"github.com/leg100/otf/rbac"
+)
+
+type PermissionsService interface {
+	GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error)
+
+	SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error
+	UnsetPermission(ctx context.Context, workspaceID, team string) error
+}
+
+// GetPolicy retrieves a workspace policy.
+//
+// NOTE: no authz protects this endpoint because it's used in the process of making
+// authz decisions.
+func (s *service) GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) {
+	return s.db.GetWorkspacePolicy(ctx, workspaceID)
+}
+
+func (s *service) SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error {
+	subject, err := s.CanAccess(ctx, rbac.SetWorkspacePermissionAction, workspaceID)
+	if err != nil {
+		return err
+	}
+
+	if err := s.db.SetWorkspacePermission(ctx, workspaceID, team, role); err != nil {
+		s.Error(err, "setting workspace permission", "subject", subject, "workspace", workspaceID)
+		return err
+	}
+
+	s.V(0).Info("set workspace permission", "team", team, "role", role, "subject", subject, "workspace", workspaceID)
+
+	// TODO: publish event
+
+	return nil
+}
+
+func (s *service) UnsetPermission(ctx context.Context, workspaceID, team string) error {
+	subject, err := s.CanAccess(ctx, rbac.UnsetWorkspacePermissionAction, workspaceID)
+	if err != nil {
+		s.Error(err, "unsetting workspace permission", "team", team, "subject", subject, "workspace", workspaceID)
+		return err
+	}
+
+	s.V(0).Info("unset workspace permission", "team", team, "subject", subject, "workspace", workspaceID)
+	// TODO: publish event
+	return s.db.UnsetWorkspacePermission(ctx, workspaceID, team)
+}
diff --git a/workspace/service.go b/workspace/service.go
index 11b93a135..3be0a30ae 100644
--- a/workspace/service.go
+++ b/workspace/service.go
@@ -12,7 +12,6 @@ import (
 	"github.com/leg100/otf/auth"
 	"github.com/leg100/otf/http/html"
 	"github.com/leg100/otf/organization"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/rbac"
 	"github.com/leg100/otf/repo"
 	"github.com/leg100/otf/vcsprovider"
@@ -41,15 +40,17 @@ type (
 		disconnect(ctx context.Context, workspaceID string) error
 
 		LockService
+		PermissionsService
+		TagService
 	}
 
 	service struct {
 		logr.Logger
 		otf.Publisher
 
-		site         otf.Authorizer
-		organization otf.Authorizer
-		workspace    otf.Authorizer // workspace authorizer
+		site           otf.Authorizer
+		organization   otf.Authorizer
+		otf.Authorizer // workspace authorizer
 
 		db   *pgdb
 		repo repo.RepoService
@@ -66,26 +67,27 @@ type (
 		repo.RepoService
 		auth.TeamService
 		logr.Logger
-		WorkspaceAuthorizer otf.Authorizer
-		policy.PolicyService
 	}
 )
 
 func NewService(opts Options) *service {
+	db := &pgdb{opts.DB}
 	svc := service{
-		Logger:       opts.Logger,
-		Publisher:    opts.Broker,
-		db:           &pgdb{opts.DB},
+		Logger:    opts.Logger,
+		Publisher: opts.Broker,
+		Authorizer: &authorizer{
+			Logger: opts.Logger,
+			db:     db,
+		},
+		db:           db,
 		repo:         opts.RepoService,
-		site:         &otf.SiteAuthorizer{Logger: opts.Logger},
 		organization: &organization.Authorizer{Logger: opts.Logger},
-		workspace:    opts.WorkspaceAuthorizer,
+		site:         &otf.SiteAuthorizer{Logger: opts.Logger},
 	}
 	svc.web = &webHandlers{
 		Renderer:           opts.Renderer,
 		TeamService:        opts.TeamService,
 		VCSProviderService: opts.VCSProviderService,
-		PolicyService:      opts.PolicyService,
 		svc:                &svc,
 	}
 	// Register with broker so that it can relay workspace events
@@ -95,6 +97,7 @@ func NewService(opts Options) *service {
 
 func (s *service) AddHandlers(r *mux.Router) {
 	s.web.addHandlers(r)
+	s.web.addTagHandlers(r)
 }
 
 func (s *service) CreateWorkspace(ctx context.Context, opts CreateOptions) (*Workspace, error) {
@@ -125,6 +128,14 @@ func (s *service) CreateWorkspace(ctx context.Context, opts CreateOptions) (*Wor
 			}
 			ws.Connection = conn
 		}
+		// Optionally create tags within same transaction
+		if len(opts.Tags) > 0 {
+			added, err := addTags(ctx, tx, ws, opts.Tags)
+			if err != nil {
+				return err
+			}
+			ws.Tags = added
+		}
 		return nil
 	})
 	if err != nil {
@@ -145,7 +156,7 @@ func (s *service) GetByID(ctx context.Context, workspaceID string) (any, error)
 }
 
 func (s *service) GetWorkspace(ctx context.Context, workspaceID string) (*Workspace, error) {
-	subject, err := s.workspace.CanAccess(ctx, rbac.GetWorkspaceAction, workspaceID)
+	subject, err := s.CanAccess(ctx, rbac.GetWorkspaceAction, workspaceID)
 	if err != nil {
 		return nil, err
 	}
@@ -168,7 +179,7 @@ func (s *service) GetWorkspaceByName(ctx context.Context, organization, workspac
 		return nil, err
 	}
 
-	subject, err := s.workspace.CanAccess(ctx, rbac.GetWorkspaceAction, ws.ID)
+	subject, err := s.CanAccess(ctx, rbac.GetWorkspaceAction, ws.ID)
 	if err != nil {
 		return nil, err
 	}
@@ -211,7 +222,7 @@ func (s *service) ListWorkspacesByRepoID(ctx context.Context, repoID uuid.UUID)
 }
 
 func (s *service) UpdateWorkspace(ctx context.Context, workspaceID string, opts UpdateOptions) (*Workspace, error) {
-	subject, err := s.workspace.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID)
+	subject, err := s.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID)
 	if err != nil {
 		return nil, err
 	}
@@ -237,7 +248,7 @@ func (s *service) UpdateWorkspace(ctx context.Context, workspaceID string, opts
 }
 
 func (s *service) DeleteWorkspace(ctx context.Context, workspaceID string) (*Workspace, error) {
-	subject, err := s.workspace.CanAccess(ctx, rbac.DeleteWorkspaceAction, workspaceID)
+	subject, err := s.CanAccess(ctx, rbac.DeleteWorkspaceAction, workspaceID)
 	if err != nil {
 		return nil, err
 	}
@@ -270,7 +281,7 @@ func (s *service) DeleteWorkspace(ctx context.Context, workspaceID string) (*Wor
 
 // connect connects the workspace to a repo.
 func (s *service) connect(ctx context.Context, workspaceID string, opts ConnectOptions) (*repo.Connection, error) {
-	_, err := s.workspace.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID)
+	_, err := s.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID)
 	if err != nil {
 		return nil, err
 	}
@@ -303,7 +314,7 @@ func (s *service) connectWithoutAuthz(ctx context.Context, workspaceID string, t
 }
 
 func (s *service) disconnect(ctx context.Context, workspaceID string) error {
-	subject, err := s.workspace.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID)
+	subject, err := s.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID)
 	if err != nil {
 		return err
 	}
diff --git a/workspace/tag.go b/workspace/tag.go
new file mode 100644
index 000000000..95a49caae
--- /dev/null
+++ b/workspace/tag.go
@@ -0,0 +1,58 @@
+package workspace
+
+import (
+	"errors"
+
+	"github.com/leg100/otf"
+	"golang.org/x/exp/slog"
+)
+
+var ErrInvalidTagSpec = errors.New("invalid tag spec: must provide either an ID or a name")
+
+type (
+	// Tag is a symbol associated with one or more workspaces. Helps searching and
+	// grouping workspaces.
+	Tag struct {
+		ID            string // ID of the form 'tag-*'. Globally unique.
+		Name          string // Meaningful symbol. Unique to an organization.
+		InstanceCount int    // Number of workspaces that have this tag
+		Organization  string // Organization this tag belongs to.
+	}
+
+	// TagList is a list of tags.
+	TagList struct {
+		*otf.Pagination
+		Items []*Tag
+	}
+
+	// TagSpec specifies a tag. Either ID or Name must be non-nil for it to
+	// valid.
+	TagSpec struct {
+		ID   string
+		Name string
+	}
+
+	TagSpecs []TagSpec
+)
+
+func (s TagSpec) Valid() error {
+	if s.ID == "" && s.Name == "" {
+		return ErrInvalidTagSpec
+	}
+	return nil
+}
+
+func (specs TagSpecs) LogValue() slog.Value {
+	var (
+		ids   []string
+		names []string
+	)
+	for _, s := range specs {
+		ids = append(ids, s.ID)
+		names = append(names, s.Name)
+	}
+	return slog.GroupValue(
+		slog.Any("ids", ids),
+		slog.Any("names", names),
+	)
+}
diff --git a/workspace/tag_db.go b/workspace/tag_db.go
new file mode 100644
index 000000000..e45cc80be
--- /dev/null
+++ b/workspace/tag_db.go
@@ -0,0 +1,171 @@
+package workspace
+
+import (
+	"context"
+
+	"github.com/jackc/pgtype"
+	"github.com/jackc/pgx/v4"
+	"github.com/leg100/otf"
+	"github.com/leg100/otf/sql"
+	"github.com/leg100/otf/sql/pggen"
+)
+
+type (
+	// pgresult represents the result of a database query for a tag.
+	tagresult struct {
+		TagID            pgtype.Text `json:"tag_id"`
+		Name             pgtype.Text `json:"name"`
+		OrganizationName pgtype.Text `json:"organization_name"`
+		InstanceCount    int         `json:"instance_count"`
+	}
+)
+
+// toTag converts a database result into a tag
+func (r tagresult) toTag() *Tag {
+	return &Tag{
+		ID:            r.TagID.String,
+		Name:          r.Name.String,
+		Organization:  r.OrganizationName.String,
+		InstanceCount: r.InstanceCount,
+	}
+}
+
+func (db *pgdb) listTags(ctx context.Context, organization string, opts ListTagsOptions) (*TagList, error) {
+	batch := &pgx.Batch{}
+
+	db.FindTagsBatch(batch, pggen.FindTagsParams{
+		OrganizationName: sql.String(organization),
+		Limit:            opts.GetLimit(),
+		Offset:           opts.GetOffset(),
+	})
+	db.CountTagsBatch(batch, sql.String(organization))
+	results := db.SendBatch(ctx, batch)
+	defer results.Close()
+
+	rows, err := db.FindTagsScan(results)
+	if err != nil {
+		return nil, sql.Error(err)
+	}
+	count, err := db.CountTagsScan(results)
+	if err != nil {
+		return nil, sql.Error(err)
+	}
+
+	var items []*Tag
+	for _, r := range rows {
+		items = append(items, tagresult(r).toTag())
+	}
+
+	return &TagList{
+		Items:      items,
+		Pagination: otf.NewPagination(opts.ListOptions, count),
+	}, nil
+}
+
+func (db *pgdb) deleteTags(ctx context.Context, organization string, tagIDs []string) error {
+	err := db.Tx(ctx, func(tx otf.DB) error {
+		for _, tid := range tagIDs {
+			_, err := tx.DeleteTag(ctx, sql.String(tid), sql.String(organization))
+			if err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	return sql.Error(err)
+}
+
+func (db *pgdb) addTag(ctx context.Context, organization, name, id string) error {
+	_, err := db.InsertTag(ctx, pggen.InsertTagParams{
+		TagID:            sql.String(id),
+		Name:             sql.String(name),
+		OrganizationName: sql.String(organization),
+	})
+	if err != nil {
+		return sql.Error(err)
+	}
+	return nil
+}
+
+func (db *pgdb) deleteTag(ctx context.Context, tag *Tag) error {
+	_, err := db.DeleteTag(ctx, sql.String(tag.ID), sql.String(tag.Organization))
+	if err != nil {
+		return sql.Error(err)
+	}
+	return nil
+}
+
+func (db *pgdb) findTagByName(ctx context.Context, organization, name string) (*Tag, error) {
+	tag, err := db.FindTagByName(ctx, sql.String(name), sql.String(organization))
+	if err != nil {
+		return nil, sql.Error(err)
+	}
+	return tagresult(tag).toTag(), nil
+}
+
+func (db *pgdb) findTagByID(ctx context.Context, organization, id string) (*Tag, error) {
+	tag, err := db.FindTagByID(ctx, sql.String(id), sql.String(organization))
+	if err != nil {
+		return nil, sql.Error(err)
+	}
+	return tagresult(tag).toTag(), nil
+}
+
+func (db *pgdb) tagWorkspace(ctx context.Context, workspaceID, tagID string) error {
+	_, err := db.InsertWorkspaceTag(ctx, sql.String(tagID), sql.String(workspaceID))
+	if err != nil {
+		return sql.Error(err)
+	}
+	return nil
+}
+
+func (db *pgdb) deleteWorkspaceTag(ctx context.Context, workspaceID, tagID string) error {
+	_, err := db.DeleteWorkspaceTag(ctx, sql.String(workspaceID), sql.String(tagID))
+	if err != nil {
+		return sql.Error(err)
+	}
+	return nil
+}
+
+func (db *pgdb) listWorkspaceTags(ctx context.Context, workspaceID string, opts ListWorkspaceTagsOptions) (*TagList, error) {
+	batch := &pgx.Batch{}
+
+	db.FindWorkspaceTagsBatch(batch, pggen.FindWorkspaceTagsParams{
+		WorkspaceID: sql.String(workspaceID),
+		Limit:       opts.GetLimit(),
+		Offset:      opts.GetOffset(),
+	})
+	db.CountTagsBatch(batch, sql.String(workspaceID))
+	results := db.SendBatch(ctx, batch)
+	defer results.Close()
+
+	rows, err := db.FindWorkspaceTagsScan(results)
+	if err != nil {
+		return nil, sql.Error(err)
+	}
+	count, err := db.CountWorkspaceTagsScan(results)
+	if err != nil {
+		return nil, sql.Error(err)
+	}
+
+	var items []*Tag
+	for _, r := range rows {
+		items = append(items, tagresult(r).toTag())
+	}
+
+	return &TagList{
+		Items:      items,
+		Pagination: otf.NewPagination(opts.ListOptions, count),
+	}, nil
+}
+
+// lockTags tags table within a transaction, providing a callback within which
+// caller can use the transaction.
+func (db *pgdb) lockTags(ctx context.Context, callback func(*pgdb) error) error {
+	return db.Tx(ctx, func(tx otf.DB) error {
+		if _, err := tx.Exec(ctx, "LOCK tags"); err != nil {
+			return err
+		}
+		return callback(&pgdb{tx})
+	})
+}
diff --git a/workspace/tag_service.go b/workspace/tag_service.go
new file mode 100644
index 000000000..25862777e
--- /dev/null
+++ b/workspace/tag_service.go
@@ -0,0 +1,262 @@
+package workspace
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/leg100/otf"
+	"github.com/leg100/otf/rbac"
+)
+
+type (
+	TagService interface {
+		// ListTags lists tags within an organization
+		ListTags(ctx context.Context, organization string, opts ListTagsOptions) (*TagList, error)
+
+		// DeleteTags deletes tags from an organization
+		DeleteTags(ctx context.Context, organization string, tagIDs []string) error
+
+		// TagWorkspaces adds an existing tag to a list of workspaces
+		TagWorkspaces(ctx context.Context, tagID string, workspaceIDs []string) error
+
+		// AddTags appends tags to a workspace. Any tag specified by ID must
+		// exist. Any tag specified by name is created if it does not
+		// exist.
+		AddTags(ctx context.Context, workspaceID string, tags []TagSpec) error
+
+		// RemoveTags removes tags from a workspace. The workspace must already
+		// exist. Any tag specifying an ID must exist. Any tag specifying a name
+		// need not exist and no action is taken. If a tag is no longer
+		// associated with any workspaces it is removed.
+		RemoveTags(ctx context.Context, workspaceID string, tags []TagSpec) error
+
+		// ListWorkspaceTags lists the tags for a workspace.
+		ListWorkspaceTags(ctx context.Context, workspaceID string, options ListWorkspaceTagsOptions) (*TagList, error)
+
+		listAllTags(ctx context.Context, organization string) ([]*Tag, error)
+	}
+
+	// ListTagsOptions are options for paginating and filtering a list of
+	// tags
+	ListTagsOptions struct {
+		otf.ListOptions
+	}
+
+	// ListWorkspaceTagsOptions are options for paginating and filtering a list of
+	// workspace tags
+	ListWorkspaceTagsOptions struct {
+		otf.ListOptions
+	}
+)
+
+func (s *service) ListTags(ctx context.Context, organization string, opts ListTagsOptions) (*TagList, error) {
+	subject, err := s.organization.CanAccess(ctx, rbac.ListTagsAction, organization)
+	if err != nil {
+		return nil, err
+	}
+
+	list, err := s.db.listTags(ctx, organization, opts)
+	if err != nil {
+		s.Error(err, "listing tags", "organization", organization, "subject", subject)
+	}
+	s.V(9).Info("listed tags", "organization", organization, "subject", subject)
+	return list, nil
+}
+
+func (s *service) DeleteTags(ctx context.Context, organization string, tagIDs []string) error {
+	subject, err := s.organization.CanAccess(ctx, rbac.DeleteTagsAction, organization)
+	if err != nil {
+		return err
+	}
+
+	if err := s.db.deleteTags(ctx, organization, tagIDs); err != nil {
+		s.Error(err, "deleting tags", "organization", organization, "tags_ids", tagIDs, "subject", subject)
+		return err
+	}
+	s.Info("deleted tags", "organization", organization, "tag_ids", tagIDs, "subject", subject)
+	return nil
+}
+
+func (s *service) TagWorkspaces(ctx context.Context, tagID string, workspaceIDs []string) error {
+	subject, err := otf.SubjectFromContext(ctx)
+	if err != nil {
+		return err
+	}
+
+	err = s.db.tx(ctx, func(tx *pgdb) error {
+		for _, wid := range workspaceIDs {
+			_, err := s.CanAccess(ctx, rbac.TagWorkspacesAction, wid)
+			if err != nil {
+				return err
+			}
+			if err := tx.tagWorkspace(ctx, wid, tagID); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		s.Error(err, "tagging tags", "tag_id", tagID, "workspace_ids", workspaceIDs, "subject", subject)
+		return err
+	}
+	s.Info("tagged workspaces", "tag_id", tagID, "workspaces_ids", workspaceIDs, "subject", subject)
+	return nil
+}
+
+func (s *service) AddTags(ctx context.Context, workspaceID string, tags []TagSpec) error {
+	subject, err := s.CanAccess(ctx, rbac.AddTagsAction, workspaceID)
+	if err != nil {
+		return err
+	}
+
+	ws, err := s.db.get(ctx, workspaceID)
+	if err != nil {
+		return fmt.Errorf("workspace not found; %s; %w", workspaceID, err)
+	}
+
+	added, err := addTags(ctx, s.db, ws, tags)
+	if err != nil {
+		s.Error(err, "adding tags", "workspace", workspaceID, "tags", TagSpecs(tags), "subject", subject)
+		return err
+	}
+	s.Info("added tags", "workspace", workspaceID, "tags", added, "subject", subject)
+	return nil
+}
+
+func (s *service) RemoveTags(ctx context.Context, workspaceID string, tags []TagSpec) error {
+	subject, err := s.CanAccess(ctx, rbac.RemoveTagsAction, workspaceID)
+	if err != nil {
+		return err
+	}
+
+	ws, err := s.db.get(ctx, workspaceID)
+	if err != nil {
+		return fmt.Errorf("workspace not found; %s; %w", workspaceID, err)
+	}
+
+	err = s.db.lockTags(ctx, func(tx *pgdb) (err error) {
+		for _, t := range tags {
+			if err := t.Valid(); err != nil {
+				return err
+			}
+			var tag *Tag
+
+			switch {
+			case t.Name != "":
+				tag, err = tx.findTagByName(ctx, ws.Organization, t.Name)
+				if errors.Is(err, otf.ErrResourceNotFound) {
+					// ignore tags that cannot be found when specified by name
+					continue
+				} else if err != nil {
+					return err
+				}
+			case t.ID != "":
+				tag, err = tx.findTagByID(ctx, ws.Organization, t.ID)
+				if err != nil {
+					return err
+				}
+			default:
+				return ErrInvalidTagSpec
+			}
+			if err := tx.deleteWorkspaceTag(ctx, workspaceID, tag.ID); err != nil {
+				return fmt.Errorf("removing tag %s from workspace %s: %w", tag.ID, workspaceID, err)
+			}
+			// Delete tag if it is no longer associated with any workspaces. If
+			// that is the case then instance count should be 1, since its last
+			// workspace has just been deleted.
+			if tag.InstanceCount == 1 {
+				if err := tx.deleteTag(ctx, tag); err != nil {
+					return fmt.Errorf("deleting tag: %w", err)
+				}
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		s.Error(err, "removing tags", "workspace", workspaceID, "tags", TagSpecs(tags), "subject", subject)
+		return err
+	}
+	s.Info("removed tags", "workspace", workspaceID, "tags", TagSpecs(tags), "subject", subject)
+	return nil
+}
+
+func (s *service) ListWorkspaceTags(ctx context.Context, workspaceID string, opts ListWorkspaceTagsOptions) (*TagList, error) {
+	subject, err := s.CanAccess(ctx, rbac.ListWorkspaceTags, workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	list, err := s.db.listWorkspaceTags(ctx, workspaceID, opts)
+	if err != nil {
+		s.Error(err, "listing workspace tags", "workspace", workspaceID, "subject", subject)
+		return nil, err
+	}
+	s.V(9).Info("listed workspace tags", "workspace", workspaceID, "subject", subject)
+	return list, nil
+}
+
+func (s *service) listAllTags(ctx context.Context, organization string) ([]*Tag, error) {
+	var (
+		tags []*Tag
+		opts ListTagsOptions
+	)
+	for {
+		page, err := s.ListTags(ctx, organization, opts)
+		if err != nil {
+			return nil, err
+		}
+		tags = append(tags, page.Items...)
+		if page.NextPage() == nil {
+			break
+		}
+		opts.PageNumber = *page.NextPage()
+	}
+	return tags, nil
+}
+
+func addTags(ctx context.Context, db *pgdb, ws *Workspace, tags []TagSpec) ([]string, error) {
+	// For each tag:
+	// (i) if specified by name, create new tag if it does not exist and get its ID.
+	// (ii) add tag to workspace
+	var added []string
+	err := db.lockTags(ctx, func(tx *pgdb) (err error) {
+		for _, t := range tags {
+			if err := t.Valid(); err != nil {
+				return err
+			}
+
+			id := t.ID
+			name := t.Name
+			switch {
+			case name != "":
+				existing, err := tx.findTagByName(ctx, ws.Organization, name)
+				if errors.Is(err, otf.ErrResourceNotFound) {
+					id = otf.NewID("tag")
+					if err := tx.addTag(ctx, ws.Organization, name, id); err != nil {
+						return err
+					}
+				} else if err != nil {
+					return err
+				} else {
+					id = existing.ID
+				}
+			case id != "":
+				existing, err := tx.findTagByID(ctx, ws.Organization, t.ID)
+				if err != nil {
+					return err
+				}
+				name = existing.Name
+			default:
+				return ErrInvalidTagSpec
+			}
+
+			if err := tx.tagWorkspace(ctx, ws.ID, id); err != nil {
+				return err
+			}
+			added = append(added, name)
+		}
+		return nil
+	})
+	return added, err
+}
diff --git a/workspace/tags_web.go b/workspace/tags_web.go
new file mode 100644
index 000000000..0a005d82c
--- /dev/null
+++ b/workspace/tags_web.go
@@ -0,0 +1,57 @@
+package workspace
+
+import (
+	"net/http"
+
+	"github.com/gorilla/mux"
+	"github.com/leg100/otf/http/decode"
+	"github.com/leg100/otf/http/html"
+	"github.com/leg100/otf/http/html/paths"
+)
+
+func (h *webHandlers) addTagHandlers(r *mux.Router) {
+	r = html.UIRouter(r)
+
+	r.HandleFunc("/workspaces/{workspace_id}/create-tag", h.createTag).Methods("POST")
+	r.HandleFunc("/workspaces/{workspace_id}/delete-tag", h.deleteTag).Methods("POST")
+}
+
+func (h *webHandlers) createTag(w http.ResponseWriter, r *http.Request) {
+	var params struct {
+		WorkspaceID *string `schema:"workspace_id,required"`
+		TagName     *string `schema:"tag_name,required"`
+	}
+	if err := decode.All(&params, r); err != nil {
+		html.Error(w, err.Error(), http.StatusUnprocessableEntity)
+		return
+	}
+
+	err := h.svc.AddTags(r.Context(), *params.WorkspaceID, []TagSpec{{Name: *params.TagName}})
+	if err != nil {
+		html.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	html.FlashSuccess(w, "created tag: "+*params.TagName)
+	http.Redirect(w, r, paths.EditWorkspace(*params.WorkspaceID), http.StatusFound)
+}
+
+func (h *webHandlers) deleteTag(w http.ResponseWriter, r *http.Request) {
+	var params struct {
+		WorkspaceID *string `schema:"workspace_id,required"`
+		TagName     *string `schema:"tag_name,required"`
+	}
+	if err := decode.All(&params, r); err != nil {
+		html.Error(w, err.Error(), http.StatusUnprocessableEntity)
+		return
+	}
+
+	err := h.svc.RemoveTags(r.Context(), *params.WorkspaceID, []TagSpec{{Name: *params.TagName}})
+	if err != nil {
+		html.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	html.FlashSuccess(w, "removed tag: "+*params.TagName)
+	http.Redirect(w, r, paths.EditWorkspace(*params.WorkspaceID), http.StatusFound)
+}
diff --git a/workspace/test_helpers.go b/workspace/test_helpers.go
index cc22974c1..427420e03 100644
--- a/workspace/test_helpers.go
+++ b/workspace/test_helpers.go
@@ -8,7 +8,6 @@ import (
 	"github.com/leg100/otf/auth"
 	"github.com/leg100/otf/cloud"
 	"github.com/leg100/otf/http/html"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/repo"
 	"github.com/leg100/otf/vcsprovider"
 	"github.com/stretchr/testify/require"
@@ -26,7 +25,6 @@ type (
 
 		auth.TeamService
 		VCSProviderService
-		policy.PolicyService
 	}
 
 	fakeWebServiceOption func(*fakeWebService)
@@ -75,7 +73,6 @@ func fakeWebHandlers(t *testing.T, opts ...fakeWebServiceOption) *webHandlers {
 		Renderer:           renderer,
 		TeamService:        &svc,
 		VCSProviderService: &svc,
-		PolicyService:      &svc,
 		svc:                &svc,
 	}
 }
@@ -147,6 +144,10 @@ func (f *fakeWebService) disconnect(context.Context, string) error {
 	return nil
 }
 
+func (f *fakeWebService) listAllTags(ctx context.Context, organization string) ([]*Tag, error) {
+	return nil, nil
+}
+
 type fakeWebCloudClient struct {
 	repos []string
 
diff --git a/workspace/web.go b/workspace/web.go
index 86a0e08bd..7c877fb69 100644
--- a/workspace/web.go
+++ b/workspace/web.go
@@ -12,7 +12,6 @@ import (
 	"github.com/leg100/otf/http/html"
 	"github.com/leg100/otf/http/html/paths"
 	"github.com/leg100/otf/organization"
-	"github.com/leg100/otf/policy"
 	"github.com/leg100/otf/rbac"
 	"github.com/leg100/otf/vcsprovider"
 )
@@ -22,7 +21,6 @@ type (
 		html.Renderer
 		auth.TeamService
 		VCSProviderService
-		policy.PolicyService
 
 		svc Service
 	}
@@ -66,32 +64,49 @@ func (h *webHandlers) addHandlers(r *mux.Router) {
 }
 
 func (h *webHandlers) listWorkspaces(w http.ResponseWriter, r *http.Request) {
-	var params struct {
-		Organization    string `schema:"organization_name,required"`
-		otf.ListOptions        // Pagination
-	}
+	var params ListOptions
 	if err := decode.All(&params, r); err != nil {
 		html.Error(w, err.Error(), http.StatusUnprocessableEntity)
 		return
 	}
 
-	workspaces, err := h.svc.ListWorkspaces(r.Context(), ListOptions{
-		Organization: &params.Organization,
-		ListOptions:  params.ListOptions,
-	})
+	workspaces, err := h.svc.ListWorkspaces(r.Context(), params)
 	if err != nil {
 		html.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
+	// retrieve all tags and create map, with each entry determining whether
+	// listing is currently filtered by the tag or not.
+	tags, err := h.svc.listAllTags(r.Context(), *params.Organization)
+	if err != nil {
+		html.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	tagfilters := func() map[string]bool {
+		m := make(map[string]bool, len(tags))
+		for _, t := range tags {
+			m[t.Name] = false
+			for _, f := range params.Tags {
+				if t.Name == f {
+					m[t.Name] = true
+					break
+				}
+			}
+		}
+		return m
+	}
+
 	h.Render("workspace_list.tmpl", w, struct {
 		organization.OrganizationPage
 		CreateWorkspaceAction rbac.Action
 		*WorkspaceList
+		TagFilters map[string]bool
 	}{
-		OrganizationPage:      organization.NewPage(r, "workspaces", params.Organization),
+		OrganizationPage:      organization.NewPage(r, "workspaces", *params.Organization),
 		CreateWorkspaceAction: rbac.CreateWorkspaceAction,
 		WorkspaceList:         workspaces,
+		TagFilters:            tagfilters(),
 	})
 }
 
@@ -148,7 +163,7 @@ func (h *webHandlers) getWorkspace(w http.ResponseWriter, r *http.Request) {
 		html.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	policy, err := h.GetPolicy(r.Context(), id)
+	policy, err := h.svc.GetPolicy(r.Context(), id)
 	if err != nil {
 		html.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -211,7 +226,7 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	policy, err := h.GetPolicy(r.Context(), workspaceID)
+	policy, err := h.svc.GetPolicy(r.Context(), workspaceID)
 	if err != nil {
 		html.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -233,16 +248,31 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	tags, err := h.svc.listAllTags(r.Context(), workspace.Organization)
+	if err != nil {
+		html.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	getTagNames := func() (names []string) {
+		for _, t := range tags {
+			names = append(names, t.Name)
+		}
+		return
+	}
+
 	h.Render("workspace_edit.tmpl", w, struct {
 		WorkspacePage
 		Policy                         otf.WorkspacePolicy
 		Unassigned                     []*auth.Team
 		Roles                          []rbac.Role
 		VCSProvider                    *vcsprovider.VCSProvider
+		UnassignedTags                 []string
 		UpdateWorkspaceAction          rbac.Action
 		DeleteWorkspaceAction          rbac.Action
 		SetWorkspacePermissionAction   rbac.Action
 		UnsetWorkspacePermissionAction rbac.Action
+		AddTagsAction                  rbac.Action
+		RemoveTagsAction               rbac.Action
 		CreateRunAction                rbac.Action
 	}{
 		WorkspacePage: NewPage(r, "edit | "+workspace.ID, workspace),
@@ -255,11 +285,14 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) {
 			rbac.WorkspaceAdminRole,
 		},
 		VCSProvider:                    provider,
+		UnassignedTags:                 otf.DiffStrings(getTagNames(), workspace.Tags),
 		UpdateWorkspaceAction:          rbac.UpdateWorkspaceAction,
 		DeleteWorkspaceAction:          rbac.DeleteWorkspaceAction,
 		SetWorkspacePermissionAction:   rbac.SetWorkspacePermissionAction,
 		UnsetWorkspacePermissionAction: rbac.UnsetWorkspacePermissionAction,
 		CreateRunAction:                rbac.CreateRunAction,
+		AddTagsAction:                  rbac.AddTagsAction,
+		RemoveTagsAction:               rbac.RemoveTagsAction,
 	})
 }
 
@@ -486,7 +519,7 @@ func (h *webHandlers) setWorkspacePermission(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	err = h.SetPermission(r.Context(), params.WorkspaceID, params.TeamName, role)
+	err = h.svc.SetPermission(r.Context(), params.WorkspaceID, params.TeamName, role)
 	if err != nil {
 		html.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -505,7 +538,7 @@ func (h *webHandlers) unsetWorkspacePermission(w http.ResponseWriter, r *http.Re
 		return
 	}
 
-	err := h.UnsetPermission(r.Context(), params.WorkspaceID, params.TeamName)
+	err := h.svc.UnsetPermission(r.Context(), params.WorkspaceID, params.TeamName)
 	if err != nil {
 		html.Error(w, err.Error(), http.StatusInternalServerError)
 		return
diff --git a/workspace/web_test.go b/workspace/web_test.go
index b5b372d2d..48fd3f642 100644
--- a/workspace/web_test.go
+++ b/workspace/web_test.go
@@ -109,8 +109,8 @@ func TestEditWorkspaceHandler(t *testing.T) {
 			user: auth.SiteAdmin,
 			want: func(t *testing.T, doc *html.Node) {
 				// always show built-in owners permission
-				findText(t, doc, "owners", "//div[@class='permissions-container']//tbody//tr[1]/td[1]")
-				findText(t, doc, "admin", "//div[@class='permissions-container']//tbody//tr[1]/td[2]")
+				findText(t, doc, "owners", "//div[@id='permissions-container']//tbody//tr[1]/td[1]")
+				findText(t, doc, "admin", "//div[@id='permissions-container']//tbody//tr[1]/td[2]")
 
 				// all buttons should be enabled
 				buttons := htmlquery.Find(doc, `//button`)
@@ -411,6 +411,8 @@ func TestFilterUnassigned(t *testing.T) {
 }
 
 func findText(t *testing.T, doc *html.Node, want, selector string) {
+	t.Helper()
+
 	got := htmlquery.FindOne(doc, selector)
 	if assert.NotNil(t, got) {
 		assert.Equal(t, want, htmlquery.InnerText(got))
diff --git a/workspace/workspace.go b/workspace/workspace.go
index 0b4a7ebdd..98d484c1d 100644
--- a/workspace/workspace.go
+++ b/workspace/workspace.go
@@ -51,6 +51,7 @@ type (
 		Organization               string
 		Connection                 *repo.Connection
 		LatestRun                  *LatestRun
+		Tags                       []string
 
 		*lock
 	}
@@ -85,6 +86,7 @@ type (
 		SourceName                 *string
 		SourceURL                  *string
 		StructuredRunOutputEnabled *bool
+		Tags                       []TagSpec
 		TerraformVersion           *string
 		TriggerPrefixes            []string
 		WorkingDirectory           *string
@@ -113,9 +115,10 @@ type (
 	// ListOptions are options for paginating and filtering a list of
 	// Workspaces
 	ListOptions struct {
-		otf.ListOptions         // Pagination
-		Prefix          string  `schema:"search[name],omitempty"`
-		Organization    *string `schema:"organization_name,required"`
+		otf.ListOptions          // Pagination
+		Prefix          string   `schema:"search[name],omitempty"`
+		Tags            []string `schema:"search[tags],omitempty"`
+		Organization    *string  `schema:"organization_name,required"`
 	}
 
 	ConnectOptions struct {