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(¶ms, 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, ¶ms); 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, ¶ms); 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, ¶ms); 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(¶ms, 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 }}