feat: add workspace secrets table, queries, and RBAC (#30248)

Adds the database table, queries, and permissions for workspace secrets: secrets set on a workspace build and delivered to the agent, never to Terraform. 

Each secret belongs to one workspace build. A new build copies the previous build's secrets, then the old copies lose their values but stay as history.
This commit is contained in:
Steven Masley
2026-10-15 13:32:40 -05:00
committed by GitHub
parent 17ec9459c0
commit 7aef110ca2
36 changed files with 1713 additions and 53 deletions
+18 -4
View File
@@ -20007,7 +20007,12 @@ const docTemplate = `{
"workspace_proxy:create",
"workspace_proxy:delete",
"workspace_proxy:read",
"workspace_proxy:update"
"workspace_proxy:update",
"workspace_secret:*",
"workspace_secret:create",
"workspace_secret:read",
"workspace_secret:read_secret",
"workspace_secret:update"
],
"x-enum-varnames": [
"APIKeyScopeAll",
@@ -20270,7 +20275,12 @@ const docTemplate = `{
"APIKeyScopeWorkspaceProxyCreate",
"APIKeyScopeWorkspaceProxyDelete",
"APIKeyScopeWorkspaceProxyRead",
"APIKeyScopeWorkspaceProxyUpdate"
"APIKeyScopeWorkspaceProxyUpdate",
"APIKeyScopeWorkspaceSecretAll",
"APIKeyScopeWorkspaceSecretCreate",
"APIKeyScopeWorkspaceSecretRead",
"APIKeyScopeWorkspaceSecretReadSecret",
"APIKeyScopeWorkspaceSecretUpdate"
]
},
"codersdk.AddLicenseRequest": {
@@ -29468,6 +29478,7 @@ const docTemplate = `{
"delete_agent",
"read",
"read_personal",
"read_secret",
"ssh",
"share",
"unassign",
@@ -29488,6 +29499,7 @@ const docTemplate = `{
"ActionDeleteAgent",
"ActionRead",
"ActionReadPersonal",
"ActionReadSecret",
"ActionSSH",
"ActionShare",
"ActionUnassign",
@@ -29557,7 +29569,8 @@ const docTemplate = `{
"workspace_agent_resource_monitor",
"workspace_build_orchestration",
"workspace_dormant",
"workspace_proxy"
"workspace_proxy",
"workspace_secret"
],
"x-enum-varnames": [
"ResourceWildcard",
@@ -29614,7 +29627,8 @@ const docTemplate = `{
"ResourceWorkspaceAgentResourceMonitor",
"ResourceWorkspaceBuildOrchestration",
"ResourceWorkspaceDormant",
"ResourceWorkspaceProxy"
"ResourceWorkspaceProxy",
"ResourceWorkspaceSecret"
]
},
"codersdk.RateLimitConfig": {
+18 -4
View File
@@ -17955,7 +17955,12 @@
"workspace_proxy:create",
"workspace_proxy:delete",
"workspace_proxy:read",
"workspace_proxy:update"
"workspace_proxy:update",
"workspace_secret:*",
"workspace_secret:create",
"workspace_secret:read",
"workspace_secret:read_secret",
"workspace_secret:update"
],
"x-enum-varnames": [
"APIKeyScopeAll",
@@ -18218,7 +18223,12 @@
"APIKeyScopeWorkspaceProxyCreate",
"APIKeyScopeWorkspaceProxyDelete",
"APIKeyScopeWorkspaceProxyRead",
"APIKeyScopeWorkspaceProxyUpdate"
"APIKeyScopeWorkspaceProxyUpdate",
"APIKeyScopeWorkspaceSecretAll",
"APIKeyScopeWorkspaceSecretCreate",
"APIKeyScopeWorkspaceSecretRead",
"APIKeyScopeWorkspaceSecretReadSecret",
"APIKeyScopeWorkspaceSecretUpdate"
]
},
"codersdk.AddLicenseRequest": {
@@ -27066,6 +27076,7 @@
"delete_agent",
"read",
"read_personal",
"read_secret",
"ssh",
"share",
"unassign",
@@ -27086,6 +27097,7 @@
"ActionDeleteAgent",
"ActionRead",
"ActionReadPersonal",
"ActionReadSecret",
"ActionSSH",
"ActionShare",
"ActionUnassign",
@@ -27155,7 +27167,8 @@
"workspace_agent_resource_monitor",
"workspace_build_orchestration",
"workspace_dormant",
"workspace_proxy"
"workspace_proxy",
"workspace_secret"
],
"x-enum-varnames": [
"ResourceWildcard",
@@ -27212,7 +27225,8 @@
"ResourceWorkspaceAgentResourceMonitor",
"ResourceWorkspaceBuildOrchestration",
"ResourceWorkspaceDormant",
"ResourceWorkspaceProxy"
"ResourceWorkspaceProxy",
"ResourceWorkspaceSecret"
]
},
"codersdk.RateLimitConfig": {
+2
View File
@@ -96,4 +96,6 @@ const (
CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds
CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces
CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces
CheckWorkspaceSecretsRequiresTarget CheckConstraint = "workspace_secrets_requires_target" // workspace_secrets
CheckWorkspaceSecretsValueCleared CheckConstraint = "workspace_secrets_value_cleared" // workspace_secrets
)
@@ -162,6 +162,16 @@ func TestInsertCustomRoles(t *testing.T) {
}),
errorContains: "not allowed to grant this permission",
},
{
// No user role holds read_secret, so no custom role can grant it.
name: "workspace-secret-read-secret-owner",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner(), rbac.ScopedRoleOrgAdmin(orgID)),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspaceSecret: {codersdk.ActionReadSecret},
}),
errorContains: "not allowed to grant this permission",
},
{
name: "user-escalation",
// These roles do not grant user perms
+99
View File
@@ -903,6 +903,24 @@ var (
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
subjectWorkspaceSecretManager = rbac.Subject{
Type: rbac.SubjectTypeWorkspaceSecretManager,
FriendlyName: "Workspace Secret Manager",
ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "workspace-secret-manager"},
DisplayName: "Workspace Secret Manager",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceWorkspaceSecret.Type: rbac.ResourceWorkspaceSecret.AvailableActions(),
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
},
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
)
// AsProvisionerd returns a context with an actor that has permissions required
@@ -1063,6 +1081,13 @@ func AsExternalAuthCoordinator(ctx context.Context) context.Context {
return As(ctx, subjectExternalAuthCoordinator)
}
// AsWorkspaceSecretManager returns a context with an actor that can read
// decrypted workspace secret values, copy them onto a new build, and clear
// superseded rows. No user role can read secret values.
func AsWorkspaceSecretManager(ctx context.Context) context.Context {
return As(ctx, subjectWorkspaceSecretManager)
}
var AsRemoveActor = rbac.Subject{
ID: "remove-actor",
}
@@ -1808,6 +1833,12 @@ func (q *querier) authorizeChatProjectMemory(ctx context.Context, action policy.
return q.authorizeContext(ctx, action, memory.RBACObject(project))
}
// workspaceSecretObject is the RBAC object for a workspace's secrets, owned
// by the workspace owner within the workspace's organization.
func workspaceSecretObject(workspace database.Workspace) rbac.Object {
return rbac.ResourceWorkspaceSecret.WithOwner(workspace.OwnerID.String()).InOrg(workspace.OrganizationID)
}
func (q *querier) AcquireExternalAuthLinkRefreshLease(ctx context.Context, arg database.AcquireExternalAuthLinkRefreshLeaseParams) (database.ExternalAuthLink, error) {
fetch := func(ctx context.Context, arg database.AcquireExternalAuthLinkRefreshLeaseParams) (database.ExternalAuthLink, error) {
return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID})
@@ -2026,6 +2057,28 @@ func (q *querier) ClearChatDiffStatusPR(ctx context.Context, arg database.ClearC
return q.db.ClearChatDiffStatusPR(ctx, arg)
}
func (q *querier) ClearWorkspaceSecretsBeforeBuild(ctx context.Context, arg database.ClearWorkspaceSecretsBeforeBuildParams) error {
workspace, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, workspaceSecretObject(workspace)); err != nil {
return err
}
return q.db.ClearWorkspaceSecretsBeforeBuild(ctx, arg)
}
func (q *querier) ClearWorkspaceSecretsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error {
workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, workspaceSecretObject(workspace)); err != nil {
return err
}
return q.db.ClearWorkspaceSecretsByWorkspaceID(ctx, workspaceID)
}
func (q *querier) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
@@ -6180,6 +6233,20 @@ func (q *querier) GetWorkspaceResourcesCreatedAfter(ctx context.Context, created
return q.db.GetWorkspaceResourcesCreatedAfter(ctx, createdAt)
}
func (q *querier) GetWorkspaceSecrets(ctx context.Context, arg database.GetWorkspaceSecretsParams) ([]database.WorkspaceSecret, error) {
// Deployment-wide listing of decrypted values exists only for the
// dbcrypt key rotation utility.
if err := q.authorizeContext(ctx, policy.ActionReadSecret, rbac.ResourceWorkspaceSecret); err != nil {
return nil, err
}
return q.db.GetWorkspaceSecrets(ctx, arg)
}
func (q *querier) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]database.GetWorkspaceSecretsHistoryRow, error) {
// Metadata only; values are never selected.
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetWorkspaceSecretsHistory)(ctx, workspaceID)
}
func (q *querier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIDs []uuid.UUID) ([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
@@ -7085,6 +7152,17 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab
return q.db.InsertWorkspaceResourceMetadata(ctx, arg)
}
func (q *querier) InsertWorkspaceSecret(ctx context.Context, arg database.InsertWorkspaceSecretParams) (database.WorkspaceSecret, error) {
workspace, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
if err != nil {
return database.WorkspaceSecret{}, err
}
if err := q.authorizeContext(ctx, policy.ActionCreate, workspaceSecretObject(workspace)); err != nil {
return database.WorkspaceSecret{}, err
}
return q.db.InsertWorkspaceSecret(ctx, arg)
}
func (q *querier) IsChatHeartbeatStale(ctx context.Context, arg database.IsChatHeartbeatStaleParams) (bool, error) {
_, err := q.GetChatByID(ctx, arg.ChatID)
if err != nil {
@@ -7203,6 +7281,15 @@ func (q *querier) ListAIGatewayKeys(ctx context.Context) ([]database.ListAIGatew
return q.db.ListAIGatewayKeys(ctx)
}
func (q *querier) ListActiveWorkspaceSecrets(ctx context.Context, workspaceBuildID uuid.UUID) ([]database.WorkspaceSecret, error) {
// Returns decrypted values. read_secret is granted only to
// AsWorkspaceSecretManager, never to user roles.
if err := q.authorizeContext(ctx, policy.ActionReadSecret, rbac.ResourceWorkspaceSecret); err != nil {
return nil, err
}
return q.db.ListActiveWorkspaceSecrets(ctx, workspaceBuildID)
}
func (q *querier) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
return nil, err
@@ -8088,6 +8175,18 @@ func (q *querier) UpdateEncryptedUserAIProviderKey(ctx context.Context, arg data
return q.db.UpdateEncryptedUserAIProviderKey(ctx, arg)
}
func (q *querier) UpdateEncryptedWorkspaceSecretValue(ctx context.Context, arg database.UpdateEncryptedWorkspaceSecretValueParams) (database.WorkspaceSecret, error) {
// Key rotation reads and rewrites values, so it needs read_secret as
// well as update. No user role holds read_secret.
if err := q.authorizeContext(ctx, policy.ActionReadSecret, rbac.ResourceWorkspaceSecret); err != nil {
return database.WorkspaceSecret{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceWorkspaceSecret); err != nil {
return database.WorkspaceSecret{}, err
}
return q.db.UpdateEncryptedWorkspaceSecretValue(ctx, arg)
}
func (q *querier) UpdateExternalAuthLink(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) {
fetch := func(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) {
return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID})
+90
View File
@@ -6930,6 +6930,69 @@ func (s *MethodTestSuite) TestUserSecrets() {
}))
}
func (s *MethodTestSuite) TestWorkspaceSecrets() {
secretObj := func(ws database.Workspace) rbac.Object {
return rbac.ResourceWorkspaceSecret.WithOwner(ws.OwnerID.String()).InOrg(ws.OrganizationID)
}
s.Run("InsertWorkspaceSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
arg := database.InsertWorkspaceSecretParams{WorkspaceID: ws.ID}
ret := testutil.Fake(s.T(), faker, database.WorkspaceSecret{WorkspaceID: ws.ID})
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
dbm.EXPECT().InsertWorkspaceSecret(gomock.Any(), arg).Return(ret, nil).AnyTimes()
check.Args(arg).Asserts(secretObj(ws), policy.ActionCreate).Returns(ret)
}))
s.Run("ClearWorkspaceSecretsBeforeBuild", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
arg := database.ClearWorkspaceSecretsBeforeBuildParams{WorkspaceID: ws.ID, WorkspaceBuildID: uuid.New()}
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
dbm.EXPECT().ClearWorkspaceSecretsBeforeBuild(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(secretObj(ws), policy.ActionUpdate)
}))
s.Run("ClearWorkspaceSecretsByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
dbm.EXPECT().ClearWorkspaceSecretsByWorkspaceID(gomock.Any(), ws.ID).Return(nil).AnyTimes()
check.Args(ws.ID).Asserts(secretObj(ws), policy.ActionUpdate)
}))
s.Run("ListActiveWorkspaceSecrets", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{})
secret := testutil.Fake(s.T(), faker, database.WorkspaceSecret{WorkspaceBuildID: build.ID})
dbm.EXPECT().ListActiveWorkspaceSecrets(gomock.Any(), build.ID).Return([]database.WorkspaceSecret{secret}, nil).AnyTimes()
check.Args(build.ID).
Asserts(rbac.ResourceWorkspaceSecret, policy.ActionReadSecret).
Returns([]database.WorkspaceSecret{secret})
}))
s.Run("GetWorkspaceSecrets", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
secret := testutil.Fake(s.T(), faker, database.WorkspaceSecret{})
arg := database.GetWorkspaceSecretsParams{AfterID: uuid.New(), LimitCount: 1000}
dbm.EXPECT().GetWorkspaceSecrets(gomock.Any(), arg).Return([]database.WorkspaceSecret{secret}, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceWorkspaceSecret, policy.ActionReadSecret).
Returns([]database.WorkspaceSecret{secret})
}))
s.Run("GetWorkspaceSecretsHistory", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
row := testutil.Fake(s.T(), faker, database.GetWorkspaceSecretsHistoryRow{
WorkspaceID: ws.ID,
WorkspaceOwnerID: ws.OwnerID,
WorkspaceOrganizationID: ws.OrganizationID,
})
dbm.EXPECT().GetWorkspaceSecretsHistory(gomock.Any(), ws.ID).Return([]database.GetWorkspaceSecretsHistoryRow{row}, nil).AnyTimes()
check.Args(ws.ID).
Asserts(row, policy.ActionRead).
Returns([]database.GetWorkspaceSecretsHistoryRow{row})
}))
s.Run("UpdateEncryptedWorkspaceSecretValue", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
secret := testutil.Fake(s.T(), faker, database.WorkspaceSecret{})
arg := database.UpdateEncryptedWorkspaceSecretValueParams{ID: secret.ID, Value: secret.Value}
dbm.EXPECT().UpdateEncryptedWorkspaceSecretValue(gomock.Any(), arg).Return(secret, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceWorkspaceSecret, policy.ActionReadSecret, rbac.ResourceWorkspaceSecret, policy.ActionUpdate).
Returns(secret)
}))
}
func (s *MethodTestSuite) TestUserSkills() {
s.Run("GetUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
@@ -8220,6 +8283,33 @@ func TestAsChatd(t *testing.T) {
})
}
func TestAsWorkspaceSecretManager(t *testing.T) {
t.Parallel()
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
secret := rbac.ResourceWorkspaceSecret.WithOwner(uuid.NewString()).InOrg(uuid.New())
ctx := dbauthz.AsWorkspaceSecretManager(context.Background())
actor, ok := dbauthz.ActorFromContext(ctx)
require.True(t, ok, "actor must be present")
for _, action := range []policy.Action{policy.ActionReadSecret, policy.ActionCreate, policy.ActionUpdate} {
require.NoError(t, auth.Authorize(ctx, actor, action, secret), "workspace secret %s should be allowed", action)
}
for _, res := range []rbac.Object{rbac.ResourceWorkspace, rbac.ResourceUserSecret, rbac.ResourceSystem} {
require.Error(t, auth.Authorize(ctx, actor, policy.ActionRead, res), "%s read should be denied", res.Type)
}
// The general system actor must not read decrypted values or write
// workspace secrets. Its wildcard read only covers metadata.
sysCtx := dbauthz.AsSystemRestricted(context.Background())
sysActor, ok := dbauthz.ActorFromContext(sysCtx)
require.True(t, ok, "actor must be present")
for _, action := range []policy.Action{policy.ActionReadSecret, policy.ActionCreate, policy.ActionUpdate} {
require.Error(t, auth.Authorize(sysCtx, sysActor, action, secret), "system workspace secret %s should be denied", action)
}
}
func TestAsExternalAuthChecker(t *testing.T) {
t.Parallel()
+21
View File
@@ -2060,6 +2060,27 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret, mutat
return userSecret
}
func WorkspaceSecret(t testing.TB, db database.Store, seed database.WorkspaceSecret) database.WorkspaceSecret {
value := seed.Value
if !value.Valid {
value = sql.NullString{String: "secret value", Valid: true}
}
secret, err := db.InsertWorkspaceSecret(genCtx, database.InsertWorkspaceSecretParams{
ID: takeFirst(seed.ID, uuid.New()),
WorkspaceID: takeFirst(seed.WorkspaceID, uuid.New()),
WorkspaceBuildID: takeFirst(seed.WorkspaceBuildID, uuid.New()),
Name: takeFirst(seed.Name, "secret-name"),
Value: value,
ValueKeyID: seed.ValueKeyID,
EnvName: takeFirst(seed.EnvName, "SECRET_ENV_NAME"),
FilePath: seed.FilePath,
Ephemeral: seed.Ephemeral,
Source: takeFirst(seed.Source, database.WorkspaceSecretSourceRequest),
})
require.NoError(t, err, "failed to insert workspace secret")
return secret
}
func ClaimPrebuild(
t testing.TB,
db database.Store,
+56
View File
@@ -312,6 +312,22 @@ func (m queryMetricsStore) ClearChatDiffStatusPR(ctx context.Context, arg databa
return r0
}
func (m queryMetricsStore) ClearWorkspaceSecretsBeforeBuild(ctx context.Context, arg database.ClearWorkspaceSecretsBeforeBuildParams) error {
start := time.Now()
r0 := m.s.ClearWorkspaceSecretsBeforeBuild(ctx, arg)
m.queryLatencies.WithLabelValues("ClearWorkspaceSecretsBeforeBuild").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ClearWorkspaceSecretsBeforeBuild").Inc()
return r0
}
func (m queryMetricsStore) ClearWorkspaceSecretsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error {
start := time.Now()
r0 := m.s.ClearWorkspaceSecretsByWorkspaceID(ctx, workspaceID)
m.queryLatencies.WithLabelValues("ClearWorkspaceSecretsByWorkspaceID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ClearWorkspaceSecretsByWorkspaceID").Inc()
return r0
}
func (m queryMetricsStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountAIBridgeSessions(ctx, arg)
@@ -4168,6 +4184,22 @@ func (m queryMetricsStore) GetWorkspaceResourcesCreatedAfter(ctx context.Context
return r0, r1
}
func (m queryMetricsStore) GetWorkspaceSecrets(ctx context.Context, arg database.GetWorkspaceSecretsParams) ([]database.WorkspaceSecret, error) {
start := time.Now()
r0, r1 := m.s.GetWorkspaceSecrets(ctx, arg)
m.queryLatencies.WithLabelValues("GetWorkspaceSecrets").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspaceSecrets").Inc()
return r0, r1
}
func (m queryMetricsStore) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]database.GetWorkspaceSecretsHistoryRow, error) {
start := time.Now()
r0, r1 := m.s.GetWorkspaceSecretsHistory(ctx, workspaceID)
m.queryLatencies.WithLabelValues("GetWorkspaceSecretsHistory").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspaceSecretsHistory").Inc()
return r0, r1
}
func (m queryMetricsStore) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) {
start := time.Now()
r0, r1 := m.s.GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx, templateIds)
@@ -4944,6 +4976,14 @@ func (m queryMetricsStore) InsertWorkspaceResourceMetadata(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) InsertWorkspaceSecret(ctx context.Context, arg database.InsertWorkspaceSecretParams) (database.WorkspaceSecret, error) {
start := time.Now()
r0, r1 := m.s.InsertWorkspaceSecret(ctx, arg)
m.queryLatencies.WithLabelValues("InsertWorkspaceSecret").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertWorkspaceSecret").Inc()
return r0, r1
}
func (m queryMetricsStore) IsChatHeartbeatStale(ctx context.Context, arg database.IsChatHeartbeatStaleParams) (bool, error) {
start := time.Now()
r0, r1 := m.s.IsChatHeartbeatStale(ctx, arg)
@@ -5056,6 +5096,14 @@ func (m queryMetricsStore) ListAIGatewayKeys(ctx context.Context) ([]database.Li
return r0, r1
}
func (m queryMetricsStore) ListActiveWorkspaceSecrets(ctx context.Context, workspaceBuildID uuid.UUID) ([]database.WorkspaceSecret, error) {
start := time.Now()
r0, r1 := m.s.ListActiveWorkspaceSecrets(ctx, workspaceBuildID)
m.queryLatencies.WithLabelValues("ListActiveWorkspaceSecrets").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListActiveWorkspaceSecrets").Inc()
return r0, r1
}
func (m queryMetricsStore) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) {
start := time.Now()
r0, r1 := m.s.ListBoundaryLogsBySessionID(ctx, arg)
@@ -5745,6 +5793,14 @@ func (m queryMetricsStore) UpdateEncryptedUserAIProviderKey(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) UpdateEncryptedWorkspaceSecretValue(ctx context.Context, arg database.UpdateEncryptedWorkspaceSecretValueParams) (database.WorkspaceSecret, error) {
start := time.Now()
r0, r1 := m.s.UpdateEncryptedWorkspaceSecretValue(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateEncryptedWorkspaceSecretValue").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateEncryptedWorkspaceSecretValue").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateExternalAuthLink(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) {
start := time.Now()
r0, r1 := m.s.UpdateExternalAuthLink(ctx, arg)
+103
View File
@@ -422,6 +422,34 @@ func (mr *MockStoreMockRecorder) ClearChatDiffStatusPR(ctx, arg any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearChatDiffStatusPR", reflect.TypeOf((*MockStore)(nil).ClearChatDiffStatusPR), ctx, arg)
}
// ClearWorkspaceSecretsBeforeBuild mocks base method.
func (m *MockStore) ClearWorkspaceSecretsBeforeBuild(ctx context.Context, arg database.ClearWorkspaceSecretsBeforeBuildParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClearWorkspaceSecretsBeforeBuild", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// ClearWorkspaceSecretsBeforeBuild indicates an expected call of ClearWorkspaceSecretsBeforeBuild.
func (mr *MockStoreMockRecorder) ClearWorkspaceSecretsBeforeBuild(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearWorkspaceSecretsBeforeBuild", reflect.TypeOf((*MockStore)(nil).ClearWorkspaceSecretsBeforeBuild), ctx, arg)
}
// ClearWorkspaceSecretsByWorkspaceID mocks base method.
func (m *MockStore) ClearWorkspaceSecretsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClearWorkspaceSecretsByWorkspaceID", ctx, workspaceID)
ret0, _ := ret[0].(error)
return ret0
}
// ClearWorkspaceSecretsByWorkspaceID indicates an expected call of ClearWorkspaceSecretsByWorkspaceID.
func (mr *MockStoreMockRecorder) ClearWorkspaceSecretsByWorkspaceID(ctx, workspaceID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearWorkspaceSecretsByWorkspaceID", reflect.TypeOf((*MockStore)(nil).ClearWorkspaceSecretsByWorkspaceID), ctx, workspaceID)
}
// CountAIBridgeSessions mocks base method.
func (m *MockStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) {
m.ctrl.T.Helper()
@@ -7856,6 +7884,36 @@ func (mr *MockStoreMockRecorder) GetWorkspaceResourcesCreatedAfter(ctx, createdA
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceResourcesCreatedAfter", reflect.TypeOf((*MockStore)(nil).GetWorkspaceResourcesCreatedAfter), ctx, createdAt)
}
// GetWorkspaceSecrets mocks base method.
func (m *MockStore) GetWorkspaceSecrets(ctx context.Context, arg database.GetWorkspaceSecretsParams) ([]database.WorkspaceSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspaceSecrets", ctx, arg)
ret0, _ := ret[0].([]database.WorkspaceSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspaceSecrets indicates an expected call of GetWorkspaceSecrets.
func (mr *MockStoreMockRecorder) GetWorkspaceSecrets(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceSecrets", reflect.TypeOf((*MockStore)(nil).GetWorkspaceSecrets), ctx, arg)
}
// GetWorkspaceSecretsHistory mocks base method.
func (m *MockStore) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]database.GetWorkspaceSecretsHistoryRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspaceSecretsHistory", ctx, workspaceID)
ret0, _ := ret[0].([]database.GetWorkspaceSecretsHistoryRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspaceSecretsHistory indicates an expected call of GetWorkspaceSecretsHistory.
func (mr *MockStoreMockRecorder) GetWorkspaceSecretsHistory(ctx, workspaceID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceSecretsHistory", reflect.TypeOf((*MockStore)(nil).GetWorkspaceSecretsHistory), ctx, workspaceID)
}
// GetWorkspaceUniqueOwnerCountByTemplateIDs mocks base method.
func (m *MockStore) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) {
m.ctrl.T.Helper()
@@ -9309,6 +9367,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), ctx, arg)
}
// InsertWorkspaceSecret mocks base method.
func (m *MockStore) InsertWorkspaceSecret(ctx context.Context, arg database.InsertWorkspaceSecretParams) (database.WorkspaceSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertWorkspaceSecret", ctx, arg)
ret0, _ := ret[0].(database.WorkspaceSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertWorkspaceSecret indicates an expected call of InsertWorkspaceSecret.
func (mr *MockStoreMockRecorder) InsertWorkspaceSecret(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceSecret", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceSecret), ctx, arg)
}
// IsChatHeartbeatStale mocks base method.
func (m *MockStore) IsChatHeartbeatStale(ctx context.Context, arg database.IsChatHeartbeatStaleParams) (bool, error) {
m.ctrl.T.Helper()
@@ -9534,6 +9607,21 @@ func (mr *MockStoreMockRecorder) ListAIGatewayKeys(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIGatewayKeys", reflect.TypeOf((*MockStore)(nil).ListAIGatewayKeys), ctx)
}
// ListActiveWorkspaceSecrets mocks base method.
func (m *MockStore) ListActiveWorkspaceSecrets(ctx context.Context, workspaceBuildID uuid.UUID) ([]database.WorkspaceSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListActiveWorkspaceSecrets", ctx, workspaceBuildID)
ret0, _ := ret[0].([]database.WorkspaceSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListActiveWorkspaceSecrets indicates an expected call of ListActiveWorkspaceSecrets.
func (mr *MockStoreMockRecorder) ListActiveWorkspaceSecrets(ctx, workspaceBuildID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListActiveWorkspaceSecrets", reflect.TypeOf((*MockStore)(nil).ListActiveWorkspaceSecrets), ctx, workspaceBuildID)
}
// ListAuthorizedAIBridgeClients mocks base method.
func (m *MockStore) ListAuthorizedAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) {
m.ctrl.T.Helper()
@@ -10889,6 +10977,21 @@ func (mr *MockStoreMockRecorder) UpdateEncryptedUserAIProviderKey(ctx, arg any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEncryptedUserAIProviderKey", reflect.TypeOf((*MockStore)(nil).UpdateEncryptedUserAIProviderKey), ctx, arg)
}
// UpdateEncryptedWorkspaceSecretValue mocks base method.
func (m *MockStore) UpdateEncryptedWorkspaceSecretValue(ctx context.Context, arg database.UpdateEncryptedWorkspaceSecretValueParams) (database.WorkspaceSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateEncryptedWorkspaceSecretValue", ctx, arg)
ret0, _ := ret[0].(database.WorkspaceSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateEncryptedWorkspaceSecretValue indicates an expected call of UpdateEncryptedWorkspaceSecretValue.
func (mr *MockStoreMockRecorder) UpdateEncryptedWorkspaceSecretValue(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEncryptedWorkspaceSecretValue", reflect.TypeOf((*MockStore)(nil).UpdateEncryptedWorkspaceSecretValue), ctx, arg)
}
// UpdateExternalAuthLink mocks base method.
func (m *MockStore) UpdateExternalAuthLink(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) {
m.ctrl.T.Helper()
+109 -1
View File
@@ -301,7 +301,12 @@ CREATE TYPE api_key_scope AS ENUM (
'organization_skill:update',
'organization_skill:delete',
'organization_skill:share',
'chat_project:share'
'chat_project:share',
'workspace_secret:*',
'workspace_secret:create',
'workspace_secret:read',
'workspace_secret:read_secret',
'workspace_secret:update'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -756,6 +761,11 @@ CREATE TYPE workspace_app_status_state AS ENUM (
'idle'
);
CREATE TYPE workspace_secret_source AS ENUM (
'request',
'carry_forward'
);
CREATE TYPE workspace_transition AS ENUM (
'start',
'stop',
@@ -1179,6 +1189,65 @@ BEGIN
END;
$$;
CREATE FUNCTION enforce_workspace_secrets_per_build_limits() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
existing_count int;
existing_total_bytes bigint;
existing_env_bytes bigint;
new_count int;
new_total_bytes bigint;
new_env_bytes bigint;
count_limit constant int := 50;
total_bytes_limit constant bigint := 204800; -- 200 KiB
env_bytes_limit constant bigint := 24576; -- 24 KiB
BEGIN
-- Serialize cap checks per build so concurrent inserts cannot all
-- observe the same pre-insert aggregates and exceed the cap.
PERFORM 1 FROM workspace_builds WHERE id = NEW.workspace_build_id FOR UPDATE;
SELECT
count(*),
coalesce(sum(octet_length(value)), 0),
coalesce(sum(octet_length(value)) FILTER (WHERE env_name <> ''), 0)
INTO existing_count, existing_total_bytes, existing_env_bytes
FROM workspace_secrets
WHERE workspace_build_id = NEW.workspace_build_id
AND cleared_at IS NULL;
new_count := existing_count + 1;
new_total_bytes := existing_total_bytes + octet_length(NEW.value);
new_env_bytes := existing_env_bytes
+ CASE WHEN NEW.env_name <> '' THEN octet_length(NEW.value) ELSE 0 END;
IF new_count > count_limit THEN
RAISE EXCEPTION 'workspace build has reached the workspace secrets count limit (% > %)',
new_count, count_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'workspace_secrets_per_build_count_limit';
END IF;
IF new_total_bytes > total_bytes_limit THEN
RAISE EXCEPTION 'workspace build has reached the workspace secrets total value bytes limit (% > %)',
new_total_bytes, total_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'workspace_secrets_per_build_total_bytes_limit';
END IF;
IF new_env_bytes > env_bytes_limit THEN
RAISE EXCEPTION 'workspace build has reached the workspace secrets env value bytes limit (% > %)',
new_env_bytes, env_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'workspace_secrets_per_build_env_bytes_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION inhibit_enqueue_if_disabled() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -4350,6 +4419,23 @@ CREATE SEQUENCE workspace_resource_metadata_id_seq
ALTER SEQUENCE workspace_resource_metadata_id_seq OWNED BY workspace_resource_metadata.id;
CREATE TABLE workspace_secrets (
id uuid DEFAULT gen_random_uuid() NOT NULL,
workspace_id uuid NOT NULL,
workspace_build_id uuid NOT NULL,
name text NOT NULL,
value text,
value_key_id text,
env_name text DEFAULT ''::text NOT NULL,
file_path text DEFAULT ''::text NOT NULL,
ephemeral boolean DEFAULT false NOT NULL,
source workspace_secret_source NOT NULL,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
cleared_at timestamp with time zone,
CONSTRAINT workspace_secrets_requires_target CHECK (((env_name <> ''::text) OR (file_path <> ''::text))),
CONSTRAINT workspace_secrets_value_cleared CHECK (((value IS NULL) = (cleared_at IS NOT NULL)))
);
CREATE VIEW workspaces_expanded AS
SELECT workspaces.id,
workspaces.created_at,
@@ -4858,6 +4944,9 @@ ALTER TABLE ONLY workspace_resource_metadata
ALTER TABLE ONLY workspace_resources
ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id);
ALTER TABLE ONLY workspace_secrets
ADD CONSTRAINT workspace_secrets_pkey PRIMARY KEY (id);
ALTER TABLE ONLY workspaces
ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
@@ -5197,6 +5286,14 @@ CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING
CREATE INDEX workspace_resources_job_id_idx ON workspace_resources USING btree (job_id);
CREATE UNIQUE INDEX workspace_secrets_build_env_name_idx ON workspace_secrets USING btree (workspace_build_id, env_name) WHERE (env_name <> ''::text);
CREATE UNIQUE INDEX workspace_secrets_build_file_path_idx ON workspace_secrets USING btree (workspace_build_id, file_path) WHERE (file_path <> ''::text);
CREATE UNIQUE INDEX workspace_secrets_build_name_idx ON workspace_secrets USING btree (workspace_build_id, name);
CREATE INDEX workspace_secrets_workspace_live_idx ON workspace_secrets USING btree (workspace_id) WHERE (cleared_at IS NULL);
CREATE INDEX workspace_template_id_idx ON workspaces USING btree (template_id) WHERE (deleted = false);
CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
@@ -5313,6 +5410,8 @@ CREATE TRIGGER trigger_upsert_user_secrets BEFORE INSERT OR UPDATE ON user_secre
CREATE TRIGGER trigger_user_secrets_per_user_limits BEFORE INSERT OR UPDATE ON user_secrets FOR EACH ROW EXECUTE FUNCTION enforce_user_secrets_per_user_limits();
CREATE TRIGGER trigger_workspace_secrets_per_build_limits BEFORE INSERT ON workspace_secrets FOR EACH ROW EXECUTE FUNCTION enforce_workspace_secrets_per_build_limits();
CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash();
CREATE TRIGGER user_status_change_trigger AFTER INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change();
@@ -5821,6 +5920,15 @@ ALTER TABLE ONLY workspace_resource_metadata
ALTER TABLE ONLY workspace_resources
ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
ALTER TABLE ONLY workspace_secrets
ADD CONSTRAINT workspace_secrets_build_workspace_fkey FOREIGN KEY (workspace_build_id, workspace_id) REFERENCES workspace_builds(id, workspace_id) ON DELETE CASCADE;
ALTER TABLE ONLY workspace_secrets
ADD CONSTRAINT workspace_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY workspace_secrets
ADD CONSTRAINT workspace_secrets_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY workspaces
ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT;
+3
View File
@@ -172,6 +172,9 @@ const (
ForeignKeyWorkspaceModulesJobID ForeignKeyConstraint = "workspace_modules_job_id_fkey" // ALTER TABLE ONLY workspace_modules ADD CONSTRAINT workspace_modules_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
ForeignKeyWorkspaceResourceMetadataWorkspaceResourceID ForeignKeyConstraint = "workspace_resource_metadata_workspace_resource_id_fkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_workspace_resource_id_fkey FOREIGN KEY (workspace_resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE;
ForeignKeyWorkspaceResourcesJobID ForeignKeyConstraint = "workspace_resources_job_id_fkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
ForeignKeyWorkspaceSecretsBuildWorkspace ForeignKeyConstraint = "workspace_secrets_build_workspace_fkey" // ALTER TABLE ONLY workspace_secrets ADD CONSTRAINT workspace_secrets_build_workspace_fkey FOREIGN KEY (workspace_build_id, workspace_id) REFERENCES workspace_builds(id, workspace_id) ON DELETE CASCADE;
ForeignKeyWorkspaceSecretsValueKeyID ForeignKeyConstraint = "workspace_secrets_value_key_id_fkey" // ALTER TABLE ONLY workspace_secrets ADD CONSTRAINT workspace_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyWorkspaceSecretsWorkspaceID ForeignKeyConstraint = "workspace_secrets_workspace_id_fkey" // ALTER TABLE ONLY workspace_secrets ADD CONSTRAINT workspace_secrets_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
ForeignKeyWorkspacesOrganizationID ForeignKeyConstraint = "workspaces_organization_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT;
ForeignKeyWorkspacesOwnerID ForeignKeyConstraint = "workspaces_owner_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT;
ForeignKeyWorkspacesTemplateID ForeignKeyConstraint = "workspaces_template_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT;
@@ -0,0 +1,6 @@
DROP TRIGGER IF EXISTS trigger_workspace_secrets_per_build_limits ON workspace_secrets;
DROP FUNCTION IF EXISTS enforce_workspace_secrets_per_build_limits();
DROP TABLE IF EXISTS workspace_secrets;
DROP TYPE IF EXISTS workspace_secret_source;
-- The workspace_secret api_key_scope values are left in place; enum values
-- cannot be dropped.
@@ -0,0 +1,143 @@
-- API key scopes for the workspace_secret RBAC resource.
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_secret:*';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_secret:create';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_secret:read';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_secret:read_secret';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_secret:update';
-- Workspace secrets are set inline on a workspace build request and
-- delivered to the workspace only through the agent manifest. Unlike rich
-- parameters they are never passed to the provisioner, so they do not
-- appear in workspace_build_parameters or Terraform state.
--
-- Each row belongs to exactly one build. When a new build is created, the
-- previous build's live, non-ephemeral rows are copied to the new build. When
-- the new build's job completes, earlier builds' rows are cleared: value and
-- value_key_id are set to NULL and cleared_at records when. Clearing waits
-- for completion because the previous build's agent can run until then.
-- Cleared rows are kept so the history of which secrets each build received
-- remains inspectable without the values.
--
-- The value, value_key_id, env_name, and file_path columns mirror
-- user_secrets so the same dbcrypt and agent manifest code paths apply.
CREATE TYPE workspace_secret_source AS ENUM (
-- Set by the build request.
'request',
-- Copied from the previous build.
'carry_forward'
);
CREATE TABLE workspace_secrets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
workspace_build_id UUID NOT NULL,
name TEXT NOT NULL,
-- The encrypted secret value (base64-encoded encrypted data). NULL once
-- the row has been cleared.
value TEXT,
value_key_id TEXT REFERENCES dbcrypt_keys(active_key_digest),
-- Environment variable name. Empty string means don't inject as env var.
env_name TEXT NOT NULL DEFAULT '',
-- File path the secret is written to. Empty string means don't write a file.
file_path TEXT NOT NULL DEFAULT '',
-- Ephemeral secrets are not copied forward to the next build.
ephemeral BOOLEAN NOT NULL DEFAULT false,
-- How the row came to be linked to its build.
source workspace_secret_source NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
-- Set when a later build superseded this row and its value was dropped.
cleared_at TIMESTAMP WITH TIME ZONE,
-- Workspace secrets have no enabled flag, so every row must have an
-- injection target.
CONSTRAINT workspace_secrets_requires_target CHECK (env_name <> '' OR file_path <> ''),
-- A row either holds a value or has been cleared, never both or neither.
CONSTRAINT workspace_secrets_value_cleared CHECK ((value IS NULL) = (cleared_at IS NOT NULL)),
-- The build must belong to the workspace. Inserts are authorized by
-- workspace_id and later reads select by workspace_build_id, so a
-- mismatched pair would place a secret on another workspace's build.
CONSTRAINT workspace_secrets_build_workspace_fkey
FOREIGN KEY (workspace_build_id, workspace_id)
REFERENCES workspace_builds(id, workspace_id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX workspace_secrets_build_name_idx ON workspace_secrets(workspace_build_id, name);
CREATE UNIQUE INDEX workspace_secrets_build_env_name_idx ON workspace_secrets(workspace_build_id, env_name)
WHERE env_name != '';
CREATE UNIQUE INDEX workspace_secrets_build_file_path_idx ON workspace_secrets(workspace_build_id, file_path)
WHERE file_path != '';
-- Live rows are cleared by workspace when a new build is created. Lookups by
-- build use workspace_secrets_build_name_idx.
CREATE INDEX workspace_secrets_workspace_live_idx ON workspace_secrets(workspace_id)
WHERE cleared_at IS NULL;
-- Per-build caps. Same limits and rationale as
-- enforce_user_secrets_per_user_limits (000509_user_secrets_limits.up.sql):
-- every live row lands in the agent manifest and env-injected rows land in
-- the agent's process env. Keep the literals in sync with
-- codersdk.MaxUserSecret*.
CREATE FUNCTION enforce_workspace_secrets_per_build_limits() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
existing_count int;
existing_total_bytes bigint;
existing_env_bytes bigint;
new_count int;
new_total_bytes bigint;
new_env_bytes bigint;
count_limit constant int := 50;
total_bytes_limit constant bigint := 204800; -- 200 KiB
env_bytes_limit constant bigint := 24576; -- 24 KiB
BEGIN
-- Serialize cap checks per build so concurrent inserts cannot all
-- observe the same pre-insert aggregates and exceed the cap.
PERFORM 1 FROM workspace_builds WHERE id = NEW.workspace_build_id FOR UPDATE;
SELECT
count(*),
coalesce(sum(octet_length(value)), 0),
coalesce(sum(octet_length(value)) FILTER (WHERE env_name <> ''), 0)
INTO existing_count, existing_total_bytes, existing_env_bytes
FROM workspace_secrets
WHERE workspace_build_id = NEW.workspace_build_id
AND cleared_at IS NULL;
new_count := existing_count + 1;
new_total_bytes := existing_total_bytes + octet_length(NEW.value);
new_env_bytes := existing_env_bytes
+ CASE WHEN NEW.env_name <> '' THEN octet_length(NEW.value) ELSE 0 END;
IF new_count > count_limit THEN
RAISE EXCEPTION 'workspace build has reached the workspace secrets count limit (% > %)',
new_count, count_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'workspace_secrets_per_build_count_limit';
END IF;
IF new_total_bytes > total_bytes_limit THEN
RAISE EXCEPTION 'workspace build has reached the workspace secrets total value bytes limit (% > %)',
new_total_bytes, total_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'workspace_secrets_per_build_total_bytes_limit';
END IF;
IF new_env_bytes > env_bytes_limit THEN
RAISE EXCEPTION 'workspace build has reached the workspace secrets env value bytes limit (% > %)',
new_env_bytes, env_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'workspace_secrets_per_build_env_bytes_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_workspace_secrets_per_build_limits
BEFORE INSERT ON workspace_secrets
FOR EACH ROW
EXECUTE FUNCTION enforce_workspace_secrets_per_build_limits();
@@ -0,0 +1,22 @@
INSERT INTO workspace_secrets (
id,
workspace_id,
workspace_build_id,
name,
value,
env_name,
file_path,
ephemeral,
source
)
VALUES (
'5b1e9d0c-2f3a-4c6e-9a7b-8d4f1e2c3b5a',
'3a9a1feb-e89d-457c-9d53-ac751b198ebe',
'a8c0b8c5-c9a8-4f33-93a4-8142e6858244',
'secret-name',
'secret value',
'SECRET_ENV_NAME',
'~/secret/file/path',
false,
'request'
);
+4
View File
@@ -286,6 +286,10 @@ func (c GetChatFileMetadataByChatIDRow) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
}
func (r GetWorkspaceSecretsHistoryRow) RBACObject() rbac.Object {
return rbac.ResourceWorkspaceSecret.WithID(r.ID).WithOwner(r.WorkspaceOwnerID.String()).InOrg(r.WorkspaceOrganizationID)
}
func (c GetChatFileDataPrefixesByIDsRow) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
}
+89 -1
View File
@@ -546,6 +546,11 @@ const (
ApiKeyScopeOrganizationSkillDelete APIKeyScope = "organization_skill:delete"
ApiKeyScopeOrganizationSkillShare APIKeyScope = "organization_skill:share"
ApiKeyScopeChatProjectShare APIKeyScope = "chat_project:share"
ApiKeyScopeWorkspaceSecret APIKeyScope = "workspace_secret:*"
ApiKeyScopeWorkspaceSecretCreate APIKeyScope = "workspace_secret:create"
ApiKeyScopeWorkspaceSecretRead APIKeyScope = "workspace_secret:read"
ApiKeyScopeWorkspaceSecretReadSecret APIKeyScope = "workspace_secret:read_secret"
ApiKeyScopeWorkspaceSecretUpdate APIKeyScope = "workspace_secret:update"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -843,7 +848,12 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeOrganizationSkillUpdate,
ApiKeyScopeOrganizationSkillDelete,
ApiKeyScopeOrganizationSkillShare,
ApiKeyScopeChatProjectShare:
ApiKeyScopeChatProjectShare,
ApiKeyScopeWorkspaceSecret,
ApiKeyScopeWorkspaceSecretCreate,
ApiKeyScopeWorkspaceSecretRead,
ApiKeyScopeWorkspaceSecretReadSecret,
ApiKeyScopeWorkspaceSecretUpdate:
return true
}
return false
@@ -1110,6 +1120,11 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeOrganizationSkillDelete,
ApiKeyScopeOrganizationSkillShare,
ApiKeyScopeChatProjectShare,
ApiKeyScopeWorkspaceSecret,
ApiKeyScopeWorkspaceSecretCreate,
ApiKeyScopeWorkspaceSecretRead,
ApiKeyScopeWorkspaceSecretReadSecret,
ApiKeyScopeWorkspaceSecretUpdate,
}
}
@@ -4820,6 +4835,64 @@ func AllWorkspaceAppStatusStateValues() []WorkspaceAppStatusState {
}
}
type WorkspaceSecretSource string
const (
WorkspaceSecretSourceRequest WorkspaceSecretSource = "request"
WorkspaceSecretSourceCarryForward WorkspaceSecretSource = "carry_forward"
)
func (e *WorkspaceSecretSource) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WorkspaceSecretSource(s)
case string:
*e = WorkspaceSecretSource(s)
default:
return fmt.Errorf("unsupported scan type for WorkspaceSecretSource: %T", src)
}
return nil
}
type NullWorkspaceSecretSource struct {
WorkspaceSecretSource WorkspaceSecretSource `json:"workspace_secret_source"`
Valid bool `json:"valid"` // Valid is true if WorkspaceSecretSource is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWorkspaceSecretSource) Scan(value interface{}) error {
if value == nil {
ns.WorkspaceSecretSource, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WorkspaceSecretSource.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWorkspaceSecretSource) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WorkspaceSecretSource), nil
}
func (e WorkspaceSecretSource) Valid() bool {
switch e {
case WorkspaceSecretSourceRequest,
WorkspaceSecretSourceCarryForward:
return true
}
return false
}
func AllWorkspaceSecretSourceValues() []WorkspaceSecretSource {
return []WorkspaceSecretSource{
WorkspaceSecretSourceRequest,
WorkspaceSecretSourceCarryForward,
}
}
type WorkspaceTransition string
const (
@@ -7053,6 +7126,21 @@ type WorkspaceResourceMetadatum struct {
ID int64 `db:"id" json:"id"`
}
type WorkspaceSecret struct {
ID uuid.UUID `db:"id" json:"id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
WorkspaceBuildID uuid.UUID `db:"workspace_build_id" json:"workspace_build_id"`
Name string `db:"name" json:"name"`
Value sql.NullString `db:"value" json:"value"`
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
EnvName string `db:"env_name" json:"env_name"`
FilePath string `db:"file_path" json:"file_path"`
Ephemeral bool `db:"ephemeral" json:"ephemeral"`
Source WorkspaceSecretSource `db:"source" json:"source"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ClearedAt sql.NullTime `db:"cleared_at" json:"cleared_at"`
}
type WorkspaceTable struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
+31
View File
@@ -93,6 +93,16 @@ type sqlcQuerier interface {
CleanTailnetTunnels(ctx context.Context) error
CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error
ClearChatDiffStatusPR(ctx context.Context, arg ClearChatDiffStatusPRParams) error
// Drops the values of every live row that belongs to an earlier build of the
// workspace (lower build number than the given build), keeping the rows so
// the history of which secrets earlier builds received stays inspectable.
// Rows of the given build and of any later build are left untouched, so a
// build that clears out of order cannot wipe a newer build's secrets. The
// cutoff build must belong to the workspace; otherwise nothing is cleared.
ClearWorkspaceSecretsBeforeBuild(ctx context.Context, arg ClearWorkspaceSecretsBeforeBuildParams) error
// Drops the values of every live row of the workspace, keeping the rows as
// history. Used when the workspace is deleted.
ClearWorkspaceSecretsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error
CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error)
CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error)
// Excluding the candidate keeps ownership takeover capacity-neutral.
@@ -1145,6 +1155,15 @@ type sqlcQuerier interface {
GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error)
GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error)
GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error)
// Returns a page of workspace secrets that still hold a value across the
// deployment, ordered by id. Pass the last returned id as after_id to fetch
// the next page. Used only by the dbcrypt key rotation utility.
GetWorkspaceSecrets(ctx context.Context, arg GetWorkspaceSecretsParams) ([]WorkspaceSecret, error)
// Returns metadata for every workspace secret row of a workspace, including
// cleared rows, so the secrets each build received can be inspected. Values
// are never selected. The workspace owner and organization are included for
// authorization.
GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]GetWorkspaceSecretsHistoryRow, error)
GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error)
// build_params is used to filter by build parameters if present.
// It has to be a CTE because the set returning function 'unnest' cannot
@@ -1309,6 +1328,7 @@ type sqlcQuerier interface {
InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error)
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
InsertWorkspaceSecret(ctx context.Context, arg InsertWorkspaceSecretParams) (WorkspaceSecret, error)
// Returns true when there is no heartbeat row for (chat_id, runner_id)
// or the existing row is older than @stale_seconds seconds by database
// time. chatstate calls this in a single query so the staleness check
@@ -1369,6 +1389,12 @@ type sqlcQuerier interface {
ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error)
ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeUserPrompt, error)
ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeysRow, error)
// Returns the live rows (value not yet cleared) linked to a build. Only the
// latest build of a workspace has live rows, so an older build returns none.
// Includes decrypted values, so this is used only by the agent manifest and
// by the build transaction that copies secrets forward; there is no REST
// endpoint that reads workspace secrets.
ListActiveWorkspaceSecrets(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceSecret, error)
// Lists boundary logs for a session, sorted by sequence number ascending.
// Supports an inclusive lower bound (seq_after) and an exclusive upper bound
// (seq_before) for fetching events between two known interceptions.
@@ -1642,6 +1668,11 @@ type sqlcQuerier interface {
UpdateEncryptedAIProviderSettings(ctx context.Context, arg UpdateEncryptedAIProviderSettingsParams) (AIProvider, error)
UpdateEncryptedChatMCPServerHeaders(ctx context.Context, arg UpdateEncryptedChatMCPServerHeadersParams) error
UpdateEncryptedUserAIProviderKey(ctx context.Context, arg UpdateEncryptedUserAIProviderKeyParams) (UserAIProviderKey, error)
// Updates only the encrypted columns on a row. Used by the dbcrypt key
// rotation utility to re-encrypt or decrypt rows in place.
// Cleared rows are skipped: they hold no value, and a row cleared between
// the rotation's list and this update must not have a value written back.
UpdateEncryptedWorkspaceSecretValue(ctx context.Context, arg UpdateEncryptedWorkspaceSecretValueParams) (WorkspaceSecret, error)
// If a refresh lease is provided, the row is only updated if the lease matches.
UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error)
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)
+284
View File
@@ -20138,3 +20138,287 @@ func TestExportOrganizationAISpend(t *testing.T) {
require.Empty(t, outOfRange)
})
}
func TestClearWorkspaceSecretsBeforeBuild(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
first := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
}).Do()
ws := first.Workspace
build1 := first.Build
build2 := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{BuildNumber: 2}).Do().Build
build3 := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{BuildNumber: 3}).Do().Build
for _, b := range []database.WorkspaceBuild{build1, build2, build3} {
dbgen.WorkspaceSecret(t, db, database.WorkspaceSecret{
WorkspaceID: ws.ID,
WorkspaceBuildID: b.ID,
Name: "api-key",
})
}
live := func(buildID uuid.UUID) int {
rows, err := db.ListActiveWorkspaceSecrets(ctx, buildID)
require.NoError(t, err)
return len(rows)
}
// A cutoff build from another workspace must clear nothing, even though
// its build number is higher than every build of this workspace.
otherBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
}).Seed(database.WorkspaceBuild{BuildNumber: 10}).Do().Build
err := db.ClearWorkspaceSecretsBeforeBuild(ctx, database.ClearWorkspaceSecretsBeforeBuildParams{
WorkspaceID: ws.ID,
WorkspaceBuildID: otherBuild.ID,
})
require.NoError(t, err)
for _, b := range []database.WorkspaceBuild{build1, build2, build3} {
require.Equal(t, 1, live(b.ID), "another workspace's build clears nothing")
}
// Build 2 clearing late must not touch build 3, which is newer.
err = db.ClearWorkspaceSecretsBeforeBuild(ctx, database.ClearWorkspaceSecretsBeforeBuildParams{
WorkspaceID: ws.ID,
WorkspaceBuildID: build2.ID,
})
require.NoError(t, err)
require.Equal(t, 0, live(build1.ID), "earlier build is cleared")
require.Equal(t, 1, live(build2.ID), "the clearing build keeps its own secrets")
require.Equal(t, 1, live(build3.ID), "a later build is untouched")
history, err := db.GetWorkspaceSecretsHistory(ctx, ws.ID)
require.NoError(t, err)
require.Len(t, history, 3, "cleared rows are kept as history")
for _, row := range history {
require.Equal(t, row.WorkspaceBuildID == build1.ID, row.ClearedAt.Valid,
"only build 1's row is cleared")
}
}
// TestInsertWorkspaceSecretBuildMismatch checks that a secret cannot name a
// build of another workspace. Inserts are authorized by workspace_id while
// reads select by workspace_build_id, so the pair must come from one build.
func TestInsertWorkspaceSecretBuildMismatch(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
newWorkspace := func() dbfake.WorkspaceResponse {
return dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
}).Do()
}
first := newWorkspace()
second := newWorkspace()
_, err := db.InsertWorkspaceSecret(ctx, database.InsertWorkspaceSecretParams{
ID: uuid.New(),
WorkspaceID: first.Workspace.ID,
WorkspaceBuildID: second.Build.ID,
Name: "api-key",
Value: sql.NullString{String: "secret", Valid: true},
EnvName: "API_KEY",
Source: database.WorkspaceSecretSourceRequest,
})
require.True(t, database.IsForeignKeyViolation(err, database.ForeignKeyWorkspaceSecretsBuildWorkspace), err)
}
// TestWorkspaceSecretLimits exercises the per-build caps enforced by
// enforce_workspace_secrets_per_build_limits: the accepted value at each cap,
// the first rejected value past it, and that a second build of the same
// workspace has its own budget.
func TestWorkspaceSecretLimits(t *testing.T) {
t.Parallel()
const (
countLimit = 50
totalBytesLimit = 204800
envBytesLimit = 24576
)
setup := func(t *testing.T) (database.Store, context.Context, dbfake.WorkspaceResponse) {
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
first := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: dbgen.Organization(t, db, database.Organization{}).ID,
OwnerID: dbgen.User(t, db, database.User{}).ID,
}).Do()
return db, ctx, first
}
insert := func(ctx context.Context, db database.Store, ws database.WorkspaceTable, build database.WorkspaceBuild, name string, valueBytes int, env bool) error {
params := database.InsertWorkspaceSecretParams{
ID: uuid.New(),
WorkspaceID: ws.ID,
WorkspaceBuildID: build.ID,
Name: name,
Value: sql.NullString{String: strings.Repeat("a", valueBytes), Valid: true},
Source: database.WorkspaceSecretSourceRequest,
}
if env {
params.EnvName = strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
} else {
params.FilePath = "/tmp/" + name
}
_, err := db.InsertWorkspaceSecret(ctx, params)
return err
}
requireLimit := func(t *testing.T, err error, constraint string) {
t.Helper()
require.True(t, database.IsCheckViolation(err, database.CheckConstraint(constraint)), err)
}
t.Run("Count", func(t *testing.T) {
t.Parallel()
db, ctx, first := setup(t)
ws, build := first.Workspace, first.Build
for i := range countLimit {
require.NoError(t, insert(ctx, db, ws, build, fmt.Sprintf("count-%02d", i), 1, true))
}
err := insert(ctx, db, ws, build, "count-over", 1, true)
requireLimit(t, err, "workspace_secrets_per_build_count_limit")
// The budget is per build.
second := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{BuildNumber: 2}).Do().Build
require.NoError(t, insert(ctx, db, ws, second, "count-next-build", 1, true))
})
t.Run("TotalBytes", func(t *testing.T) {
t.Parallel()
db, ctx, first := setup(t)
ws, build := first.Workspace, first.Build
// File secrets do not count toward the env budget, so the total
// budget can be filled exactly.
require.NoError(t, insert(ctx, db, ws, build, "total-a", totalBytesLimit-1, false))
require.NoError(t, insert(ctx, db, ws, build, "total-b", 1, false))
err := insert(ctx, db, ws, build, "total-over", 1, false)
requireLimit(t, err, "workspace_secrets_per_build_total_bytes_limit")
second := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{BuildNumber: 2}).Do().Build
require.NoError(t, insert(ctx, db, ws, second, "total-next-build", totalBytesLimit, false))
})
t.Run("EnvBytes", func(t *testing.T) {
t.Parallel()
db, ctx, first := setup(t)
ws, build := first.Workspace, first.Build
require.NoError(t, insert(ctx, db, ws, build, "env-a", envBytesLimit-1, true))
require.NoError(t, insert(ctx, db, ws, build, "env-b", 1, true))
err := insert(ctx, db, ws, build, "env-over", 1, true)
requireLimit(t, err, "workspace_secrets_per_build_env_bytes_limit")
// The env budget is full, but the total budget is not.
require.NoError(t, insert(ctx, db, ws, build, "env-full-file", 1, false))
second := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{BuildNumber: 2}).Do().Build
require.NoError(t, insert(ctx, db, ws, second, "env-next-build", envBytesLimit, true))
})
}
func TestClearWorkspaceSecretsByWorkspaceID(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
newWorkspace := func() dbfake.WorkspaceResponse {
resp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
}).Do()
dbgen.WorkspaceSecret(t, db, database.WorkspaceSecret{
WorkspaceID: resp.Workspace.ID,
WorkspaceBuildID: resp.Build.ID,
})
return resp
}
deleted := newWorkspace()
other := newWorkspace()
require.NoError(t, db.ClearWorkspaceSecretsByWorkspaceID(ctx, deleted.Workspace.ID))
live, err := db.ListActiveWorkspaceSecrets(ctx, deleted.Build.ID)
require.NoError(t, err)
require.Empty(t, live, "the workspace's secrets are cleared")
history, err := db.GetWorkspaceSecretsHistory(ctx, deleted.Workspace.ID)
require.NoError(t, err)
require.Len(t, history, 1, "cleared rows are kept as history")
require.True(t, history[0].ClearedAt.Valid)
live, err = db.ListActiveWorkspaceSecrets(ctx, other.Build.ID)
require.NoError(t, err)
require.Len(t, live, 1, "other workspaces are untouched")
}
func TestGetWorkspaceSecretsPaginates(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
first := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: dbgen.Organization(t, db, database.Organization{}).ID,
OwnerID: dbgen.User(t, db, database.User{}).ID,
}).Do()
ws := first.Workspace
second := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{BuildNumber: 2}).Do().Build
// Build 1's secret is cleared below and must not be listed.
dbgen.WorkspaceSecret(t, db, database.WorkspaceSecret{
WorkspaceID: ws.ID,
WorkspaceBuildID: first.Build.ID,
Name: "cleared",
})
var want []uuid.UUID
for i := range 5 {
secret := dbgen.WorkspaceSecret(t, db, database.WorkspaceSecret{
WorkspaceID: ws.ID,
WorkspaceBuildID: second.ID,
Name: fmt.Sprintf("secret-%d", i),
EnvName: fmt.Sprintf("SECRET_%d", i),
})
want = append(want, secret.ID)
}
require.NoError(t, db.ClearWorkspaceSecretsBeforeBuild(ctx, database.ClearWorkspaceSecretsBeforeBuildParams{
WorkspaceID: ws.ID,
WorkspaceBuildID: second.ID,
}))
slices.SortFunc(want, func(a, b uuid.UUID) int { return bytes.Compare(a[:], b[:]) })
var got []uuid.UUID
var pages int
afterID := uuid.Nil
for {
page, err := db.GetWorkspaceSecrets(ctx, database.GetWorkspaceSecretsParams{
AfterID: afterID,
LimitCount: 2,
})
require.NoError(t, err)
if len(page) == 0 {
break
}
pages++
for _, secret := range page {
got = append(got, secret.ID)
}
afterID = page[len(page)-1].ID
}
require.Equal(t, want, got, "every live secret is listed once, in id order")
require.Equal(t, 3, pages)
}
+331
View File
@@ -34745,6 +34745,337 @@ func (q *sqlQuerier) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (
return i, err
}
const clearWorkspaceSecretsBeforeBuild = `-- name: ClearWorkspaceSecretsBeforeBuild :exec
UPDATE workspace_secrets
SET
value = NULL,
value_key_id = NULL,
cleared_at = CURRENT_TIMESTAMP
FROM workspace_builds
WHERE workspace_secrets.workspace_build_id = workspace_builds.id
AND workspace_secrets.workspace_id = $1
AND workspace_secrets.cleared_at IS NULL
AND workspace_builds.build_number < (
SELECT current_build.build_number
FROM workspace_builds AS current_build
WHERE current_build.id = $2
AND current_build.workspace_id = $1
)
`
type ClearWorkspaceSecretsBeforeBuildParams struct {
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
WorkspaceBuildID uuid.UUID `db:"workspace_build_id" json:"workspace_build_id"`
}
// Drops the values of every live row that belongs to an earlier build of the
// workspace (lower build number than the given build), keeping the rows so
// the history of which secrets earlier builds received stays inspectable.
// Rows of the given build and of any later build are left untouched, so a
// build that clears out of order cannot wipe a newer build's secrets. The
// cutoff build must belong to the workspace; otherwise nothing is cleared.
func (q *sqlQuerier) ClearWorkspaceSecretsBeforeBuild(ctx context.Context, arg ClearWorkspaceSecretsBeforeBuildParams) error {
_, err := q.db.ExecContext(ctx, clearWorkspaceSecretsBeforeBuild, arg.WorkspaceID, arg.WorkspaceBuildID)
return err
}
const clearWorkspaceSecretsByWorkspaceID = `-- name: ClearWorkspaceSecretsByWorkspaceID :exec
UPDATE workspace_secrets
SET
value = NULL,
value_key_id = NULL,
cleared_at = CURRENT_TIMESTAMP
WHERE workspace_id = $1
AND cleared_at IS NULL
`
// Drops the values of every live row of the workspace, keeping the rows as
// history. Used when the workspace is deleted.
func (q *sqlQuerier) ClearWorkspaceSecretsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, clearWorkspaceSecretsByWorkspaceID, workspaceID)
return err
}
const getWorkspaceSecrets = `-- name: GetWorkspaceSecrets :many
SELECT id, workspace_id, workspace_build_id, name, value, value_key_id, env_name, file_path, ephemeral, source, created_at, cleared_at
FROM workspace_secrets
WHERE value IS NOT NULL
AND id > $1::uuid
ORDER BY id
LIMIT $2::int
`
type GetWorkspaceSecretsParams struct {
AfterID uuid.UUID `db:"after_id" json:"after_id"`
LimitCount int32 `db:"limit_count" json:"limit_count"`
}
// Returns a page of workspace secrets that still hold a value across the
// deployment, ordered by id. Pass the last returned id as after_id to fetch
// the next page. Used only by the dbcrypt key rotation utility.
func (q *sqlQuerier) GetWorkspaceSecrets(ctx context.Context, arg GetWorkspaceSecretsParams) ([]WorkspaceSecret, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceSecrets, arg.AfterID, arg.LimitCount)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceSecret
for rows.Next() {
var i WorkspaceSecret
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.WorkspaceBuildID,
&i.Name,
&i.Value,
&i.ValueKeyID,
&i.EnvName,
&i.FilePath,
&i.Ephemeral,
&i.Source,
&i.CreatedAt,
&i.ClearedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkspaceSecretsHistory = `-- name: GetWorkspaceSecretsHistory :many
SELECT
ws.id, ws.workspace_id, ws.workspace_build_id, ws.name,
ws.env_name, ws.file_path, ws.ephemeral, ws.source, ws.created_at, ws.cleared_at,
w.owner_id AS workspace_owner_id,
w.organization_id AS workspace_organization_id
FROM workspace_secrets ws
JOIN workspaces w ON w.id = ws.workspace_id
WHERE ws.workspace_id = $1
ORDER BY ws.created_at ASC, ws.name ASC
`
type GetWorkspaceSecretsHistoryRow struct {
ID uuid.UUID `db:"id" json:"id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
WorkspaceBuildID uuid.UUID `db:"workspace_build_id" json:"workspace_build_id"`
Name string `db:"name" json:"name"`
EnvName string `db:"env_name" json:"env_name"`
FilePath string `db:"file_path" json:"file_path"`
Ephemeral bool `db:"ephemeral" json:"ephemeral"`
Source WorkspaceSecretSource `db:"source" json:"source"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ClearedAt sql.NullTime `db:"cleared_at" json:"cleared_at"`
WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"`
WorkspaceOrganizationID uuid.UUID `db:"workspace_organization_id" json:"workspace_organization_id"`
}
// Returns metadata for every workspace secret row of a workspace, including
// cleared rows, so the secrets each build received can be inspected. Values
// are never selected. The workspace owner and organization are included for
// authorization.
func (q *sqlQuerier) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]GetWorkspaceSecretsHistoryRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceSecretsHistory, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetWorkspaceSecretsHistoryRow
for rows.Next() {
var i GetWorkspaceSecretsHistoryRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.WorkspaceBuildID,
&i.Name,
&i.EnvName,
&i.FilePath,
&i.Ephemeral,
&i.Source,
&i.CreatedAt,
&i.ClearedAt,
&i.WorkspaceOwnerID,
&i.WorkspaceOrganizationID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertWorkspaceSecret = `-- name: InsertWorkspaceSecret :one
INSERT INTO workspace_secrets (
id,
workspace_id,
workspace_build_id,
name,
value,
value_key_id,
env_name,
file_path,
ephemeral,
source
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10
)
RETURNING id, workspace_id, workspace_build_id, name, value, value_key_id, env_name, file_path, ephemeral, source, created_at, cleared_at
`
type InsertWorkspaceSecretParams struct {
ID uuid.UUID `db:"id" json:"id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
WorkspaceBuildID uuid.UUID `db:"workspace_build_id" json:"workspace_build_id"`
Name string `db:"name" json:"name"`
Value sql.NullString `db:"value" json:"value"`
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
EnvName string `db:"env_name" json:"env_name"`
FilePath string `db:"file_path" json:"file_path"`
Ephemeral bool `db:"ephemeral" json:"ephemeral"`
Source WorkspaceSecretSource `db:"source" json:"source"`
}
func (q *sqlQuerier) InsertWorkspaceSecret(ctx context.Context, arg InsertWorkspaceSecretParams) (WorkspaceSecret, error) {
row := q.db.QueryRowContext(ctx, insertWorkspaceSecret,
arg.ID,
arg.WorkspaceID,
arg.WorkspaceBuildID,
arg.Name,
arg.Value,
arg.ValueKeyID,
arg.EnvName,
arg.FilePath,
arg.Ephemeral,
arg.Source,
)
var i WorkspaceSecret
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.WorkspaceBuildID,
&i.Name,
&i.Value,
&i.ValueKeyID,
&i.EnvName,
&i.FilePath,
&i.Ephemeral,
&i.Source,
&i.CreatedAt,
&i.ClearedAt,
)
return i, err
}
const listActiveWorkspaceSecrets = `-- name: ListActiveWorkspaceSecrets :many
SELECT id, workspace_id, workspace_build_id, name, value, value_key_id, env_name, file_path, ephemeral, source, created_at, cleared_at
FROM workspace_secrets
WHERE workspace_build_id = $1
AND cleared_at IS NULL
ORDER BY name ASC
`
// Returns the live rows (value not yet cleared) linked to a build. Only the
// latest build of a workspace has live rows, so an older build returns none.
// Includes decrypted values, so this is used only by the agent manifest and
// by the build transaction that copies secrets forward; there is no REST
// endpoint that reads workspace secrets.
func (q *sqlQuerier) ListActiveWorkspaceSecrets(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceSecret, error) {
rows, err := q.db.QueryContext(ctx, listActiveWorkspaceSecrets, workspaceBuildID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceSecret
for rows.Next() {
var i WorkspaceSecret
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.WorkspaceBuildID,
&i.Name,
&i.Value,
&i.ValueKeyID,
&i.EnvName,
&i.FilePath,
&i.Ephemeral,
&i.Source,
&i.CreatedAt,
&i.ClearedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateEncryptedWorkspaceSecretValue = `-- name: UpdateEncryptedWorkspaceSecretValue :one
UPDATE workspace_secrets
SET
value = $1,
value_key_id = $2
WHERE id = $3
AND cleared_at IS NULL
RETURNING id, workspace_id, workspace_build_id, name, value, value_key_id, env_name, file_path, ephemeral, source, created_at, cleared_at
`
type UpdateEncryptedWorkspaceSecretValueParams struct {
Value sql.NullString `db:"value" json:"value"`
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
ID uuid.UUID `db:"id" json:"id"`
}
// Updates only the encrypted columns on a row. Used by the dbcrypt key
// rotation utility to re-encrypt or decrypt rows in place.
// Cleared rows are skipped: they hold no value, and a row cleared between
// the rotation's list and this update must not have a value written back.
func (q *sqlQuerier) UpdateEncryptedWorkspaceSecretValue(ctx context.Context, arg UpdateEncryptedWorkspaceSecretValueParams) (WorkspaceSecret, error) {
row := q.db.QueryRowContext(ctx, updateEncryptedWorkspaceSecretValue, arg.Value, arg.ValueKeyID, arg.ID)
var i WorkspaceSecret
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.WorkspaceBuildID,
&i.Name,
&i.Value,
&i.ValueKeyID,
&i.EnvName,
&i.FilePath,
&i.Ephemeral,
&i.Source,
&i.CreatedAt,
&i.ClearedAt,
)
return i, err
}
const deleteStaleWorkspaceAgentContextResources = `-- name: DeleteStaleWorkspaceAgentContextResources :exec
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id = $1
@@ -0,0 +1,110 @@
-- name: ListActiveWorkspaceSecrets :many
-- Returns the live rows (value not yet cleared) linked to a build. Only the
-- latest build of a workspace has live rows, so an older build returns none.
-- Includes decrypted values, so this is used only by the agent manifest and
-- by the build transaction that copies secrets forward; there is no REST
-- endpoint that reads workspace secrets.
SELECT *
FROM workspace_secrets
WHERE workspace_build_id = @workspace_build_id
AND cleared_at IS NULL
ORDER BY name ASC;
-- name: InsertWorkspaceSecret :one
INSERT INTO workspace_secrets (
id,
workspace_id,
workspace_build_id,
name,
value,
value_key_id,
env_name,
file_path,
ephemeral,
source
) VALUES (
@id,
@workspace_id,
@workspace_build_id,
@name,
@value,
@value_key_id,
@env_name,
@file_path,
@ephemeral,
@source
)
RETURNING *;
-- name: ClearWorkspaceSecretsBeforeBuild :exec
-- Drops the values of every live row that belongs to an earlier build of the
-- workspace (lower build number than the given build), keeping the rows so
-- the history of which secrets earlier builds received stays inspectable.
-- Rows of the given build and of any later build are left untouched, so a
-- build that clears out of order cannot wipe a newer build's secrets. The
-- cutoff build must belong to the workspace; otherwise nothing is cleared.
UPDATE workspace_secrets
SET
value = NULL,
value_key_id = NULL,
cleared_at = CURRENT_TIMESTAMP
FROM workspace_builds
WHERE workspace_secrets.workspace_build_id = workspace_builds.id
AND workspace_secrets.workspace_id = @workspace_id
AND workspace_secrets.cleared_at IS NULL
AND workspace_builds.build_number < (
SELECT current_build.build_number
FROM workspace_builds AS current_build
WHERE current_build.id = @workspace_build_id
AND current_build.workspace_id = @workspace_id
);
-- name: ClearWorkspaceSecretsByWorkspaceID :exec
-- Drops the values of every live row of the workspace, keeping the rows as
-- history. Used when the workspace is deleted.
UPDATE workspace_secrets
SET
value = NULL,
value_key_id = NULL,
cleared_at = CURRENT_TIMESTAMP
WHERE workspace_id = @workspace_id
AND cleared_at IS NULL;
-- name: GetWorkspaceSecrets :many
-- Returns a page of workspace secrets that still hold a value across the
-- deployment, ordered by id. Pass the last returned id as after_id to fetch
-- the next page. Used only by the dbcrypt key rotation utility.
SELECT *
FROM workspace_secrets
WHERE value IS NOT NULL
AND id > @after_id::uuid
ORDER BY id
LIMIT @limit_count::int;
-- name: UpdateEncryptedWorkspaceSecretValue :one
-- Updates only the encrypted columns on a row. Used by the dbcrypt key
-- rotation utility to re-encrypt or decrypt rows in place.
-- Cleared rows are skipped: they hold no value, and a row cleared between
-- the rotation's list and this update must not have a value written back.
UPDATE workspace_secrets
SET
value = @value,
value_key_id = @value_key_id
WHERE id = @id
AND cleared_at IS NULL
RETURNING *;
-- name: GetWorkspaceSecretsHistory :many
-- Returns metadata for every workspace secret row of a workspace, including
-- cleared rows, so the secrets each build received can be inspected. Values
-- are never selected. The workspace owner and organization are included for
-- authorization.
SELECT
ws.id, ws.workspace_id, ws.workspace_build_id, ws.name,
ws.env_name, ws.file_path, ws.ephemeral, ws.source, ws.created_at, ws.cleared_at,
w.owner_id AS workspace_owner_id,
w.organization_id AS workspace_organization_id
FROM workspace_secrets ws
JOIN workspaces w ON w.id = ws.workspace_id
WHERE ws.workspace_id = @workspace_id
ORDER BY ws.created_at ASC, ws.name ASC;
+4
View File
@@ -155,6 +155,7 @@ const (
UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key);
UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id);
UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id);
UniqueWorkspaceSecretsPkey UniqueConstraint = "workspace_secrets_pkey" // ALTER TABLE ONLY workspace_secrets ADD CONSTRAINT workspace_secrets_pkey PRIMARY KEY (id);
UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
UniqueAIGatewayKeysHashedSecretIndex UniqueConstraint = "ai_gateway_keys_hashed_secret_idx" // CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret);
UniqueAIGatewayKeysNameIndex UniqueConstraint = "ai_gateway_keys_name_idx" // CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name));
@@ -191,5 +192,8 @@ const (
UniqueWebpushSubscriptionsUserIDEndpointIndex UniqueConstraint = "webpush_subscriptions_user_id_endpoint_idx" // CREATE UNIQUE INDEX webpush_subscriptions_user_id_endpoint_idx ON webpush_subscriptions USING btree (user_id, endpoint);
UniqueWorkspaceAppAuditSessionsUniqueIndex UniqueConstraint = "workspace_app_audit_sessions_unique_index" // CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code);
UniqueWorkspaceProxiesLowerNameIndex UniqueConstraint = "workspace_proxies_lower_name_idx" // CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false);
UniqueWorkspaceSecretsBuildEnvNameIndex UniqueConstraint = "workspace_secrets_build_env_name_idx" // CREATE UNIQUE INDEX workspace_secrets_build_env_name_idx ON workspace_secrets USING btree (workspace_build_id, env_name) WHERE (env_name <> ''::text);
UniqueWorkspaceSecretsBuildFilePathIndex UniqueConstraint = "workspace_secrets_build_file_path_idx" // CREATE UNIQUE INDEX workspace_secrets_build_file_path_idx ON workspace_secrets USING btree (workspace_build_id, file_path) WHERE (file_path <> ''::text);
UniqueWorkspaceSecretsBuildNameIndex UniqueConstraint = "workspace_secrets_build_name_idx" // CREATE UNIQUE INDEX workspace_secrets_build_name_idx ON workspace_secrets USING btree (workspace_build_id, name);
UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
)
+1
View File
@@ -90,6 +90,7 @@ const (
SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader"
SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner"
SubjectTypeExternalAuthCoordinator SubjectType = "external_auth_coordinator"
SubjectTypeWorkspaceSecretManager SubjectType = "workspace_secret_manager"
)
const (
+13
View File
@@ -537,6 +537,17 @@ var (
ResourceWorkspaceProxy = Object{
Type: "workspace_proxy",
}
// ResourceWorkspaceSecret
// Valid Actions
// - "ActionCreate" :: set a workspace secret on a workspace build
// - "ActionRead" :: read workspace secret metadata, never the value
// - "ActionReadSecret" :: read decrypted workspace secret values, granted only to the workspace secret manager actor
// - "ActionUpdate" :: clear or re-encrypt workspace secret values
// Workspace secrets are write-only for users. No built-in user role is granted read_secret.
ResourceWorkspaceSecret = Object{
Type: "workspace_secret",
}
)
func AllResources() []Objecter {
@@ -596,6 +607,7 @@ func AllResources() []Objecter {
ResourceWorkspaceBuildOrchestration,
ResourceWorkspaceDormant,
ResourceWorkspaceProxy,
ResourceWorkspaceSecret,
}
}
@@ -609,6 +621,7 @@ func AllActions() []policy.Action {
policy.ActionDeleteAgent,
policy.ActionRead,
policy.ActionReadPersonal,
policy.ActionReadSecret,
policy.ActionSSH,
policy.ActionShare,
policy.ActionUnassign,
+12
View File
@@ -30,6 +30,8 @@ const (
ActionUpdateAgent Action = "update_agent"
ActionShare Action = "share"
ActionReadSecret Action = "read_secret"
)
type PermissionDefinition struct {
@@ -427,6 +429,16 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionDelete: "delete a user secret",
},
},
"workspace_secret": {
Actions: map[Action]ActionDefinition{
ActionCreate: "set a workspace secret on a workspace build",
ActionRead: "read workspace secret metadata, never the value",
ActionUpdate: "clear or re-encrypt workspace secret values",
ActionReadSecret: "read decrypted workspace secret values, granted only to the workspace secret manager actor",
},
Comment: `
// Workspace secrets are write-only for users. No built-in user role is granted read_secret.`,
},
"user_skill": {
Actions: map[Action]ActionDefinition{
ActionCreate: "create a user skill",
+16 -3
View File
@@ -225,6 +225,13 @@ func DefaultOrgMemberRoles() []string {
return []string{orgWorkspaceAccess, agentsAccess}
}
// workspaceSecretUserActions is every workspace secret action except
// read_secret. Decrypted values are readable only by the workspace secret
// manager actor, so no built-in user role, including owner, may read them.
func workspaceSecretUserActions() []policy.Action {
return slice.Omit(ResourceWorkspaceSecret.AvailableActions(), policy.ActionReadSecret)
}
// orgWorkspaceAccessMemberPerms returns the member-scoped permissions
// granted by the organization-workspace-access role: the ability to
// create and operate your own workspaces in the organization. The
@@ -236,6 +243,10 @@ func orgWorkspaceAccessMemberPerms() []Permission {
return Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: ResourceWorkspace.AvailableActions(),
// Members set secrets on their own workspace builds but can never
// read the values back.
ResourceWorkspaceSecret.Type: workspaceSecretUserActions(),
// Dormant workspaces share the workspace action set minus the
// build, ssh, and exec actions.
ResourceWorkspaceDormant.Type: {
@@ -408,10 +419,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec.
// Owners can inspect and delete personal skills for operability and
// abuse handling, but cannot create or edit user-authored instructions.
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat, ResourceAIGatewayKey),
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat, ResourceAIGatewayKey, ResourceWorkspaceSecret),
// This adds back in the Workspace permissions.
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: ownerWorkspaceActions,
ResourceWorkspaceSecret.Type: workspaceSecretUserActions(),
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
ResourceUserSkill.Type: {policy.ActionRead, policy.ActionDelete},
// Owners manage AI Gateway keys but cannot update them. The
@@ -444,7 +456,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
denyPermissions...,
),
User: append(
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat),
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat, ResourceWorkspaceSecret),
Permissions(map[string][]policy.Action{
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
@@ -586,8 +598,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Org admins should not have workspace exec perms.
organizationID.String(): {
Org: append(
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat, ResourceWorkspaceBuildOrchestration),
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat, ResourceWorkspaceBuildOrchestration, ResourceWorkspaceSecret),
Permissions(map[string][]policy.Action{
ResourceWorkspaceSecret.Type: workspaceSecretUserActions(),
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
// PrebuiltWorkspaces are a subset of Workspaces.
+28
View File
@@ -1250,6 +1250,34 @@ func TestRolePermissions(t *testing.T) {
},
},
},
// Workspace secrets are write-only: users who can build the workspace
// can set, clear, and list metadata, but nobody reads values back.
{
Name: "WorkspaceSecretsWrite",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
Resource: rbac.ResourceWorkspaceSecret.WithOwner(currentUser.String()).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
// orgMemberMe holds only the org-member floor role, which
// grants no workspace access.
true: {owner, orgAdmin, orgWorkspaceAccessUser},
false: {
orgMemberMe, memberMe, otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
},
},
{
Name: "WorkspaceSecretsReadSecret",
Actions: []policy.Action{policy.ActionReadSecret},
Resource: rbac.ResourceWorkspaceSecret.WithOwner(currentUser.String()).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
false: {
owner, orgMemberMe, orgAdmin, memberMe, orgWorkspaceAccessUser,
otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
},
},
// Skills are user-authored instructions, not secrets. Owners can inspect
// and delete them, but only the user can create or update them.
{
+10 -4
View File
@@ -131,18 +131,24 @@ var compositePerms = map[ScopeName]map[string][]policy.Action{
"coder:workspaces.create": {
ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse},
ResourceWorkspace.Type: {policy.ActionWorkspaceStop, policy.ActionWorkspaceStart, policy.ActionCreate, policy.ActionUpdate, policy.ActionRead},
// Builds may set workspace secrets.
ResourceWorkspaceSecret.Type: {policy.ActionCreate},
// When creating a workspace, users need to be able to read the org member the
// workspace will be owned by. Even if that owner is "yourself".
ResourceOrganizationMember.Type: {policy.ActionRead},
},
"coder:workspaces.operate": {
ResourceTemplate.Type: {policy.ActionRead},
ResourceWorkspace.Type: {policy.ActionWorkspaceStop, policy.ActionWorkspaceStart, policy.ActionRead, policy.ActionUpdate},
ResourceTemplate.Type: {policy.ActionRead},
ResourceWorkspace.Type: {policy.ActionWorkspaceStop, policy.ActionWorkspaceStart, policy.ActionRead, policy.ActionUpdate},
// Builds may set workspace secrets.
ResourceWorkspaceSecret.Type: {policy.ActionCreate},
ResourceOrganizationMember.Type: {policy.ActionRead},
},
"coder:workspaces.delete": {
ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse},
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionDelete},
ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse},
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionDelete},
// Builds may set workspace secrets.
ResourceWorkspaceSecret.Type: {policy.ActionCreate},
ResourceOrganizationMember.Type: {policy.ActionRead},
},
"coder:workspaces.access": {
+13 -1
View File
@@ -203,6 +203,10 @@ const (
ScopeWorkspaceProxyDelete ScopeName = "workspace_proxy:delete"
ScopeWorkspaceProxyRead ScopeName = "workspace_proxy:read"
ScopeWorkspaceProxyUpdate ScopeName = "workspace_proxy:update"
ScopeWorkspaceSecretCreate ScopeName = "workspace_secret:create"
ScopeWorkspaceSecretRead ScopeName = "workspace_secret:read"
ScopeWorkspaceSecretReadSecret ScopeName = "workspace_secret:read_secret"
ScopeWorkspaceSecretUpdate ScopeName = "workspace_secret:update"
)
// Valid reports whether the ScopeName matches one of the known scope values.
@@ -409,7 +413,11 @@ func (e ScopeName) Valid() bool {
ScopeWorkspaceProxyCreate,
ScopeWorkspaceProxyDelete,
ScopeWorkspaceProxyRead,
ScopeWorkspaceProxyUpdate:
ScopeWorkspaceProxyUpdate,
ScopeWorkspaceSecretCreate,
ScopeWorkspaceSecretRead,
ScopeWorkspaceSecretReadSecret,
ScopeWorkspaceSecretUpdate:
return true
}
return false
@@ -618,5 +626,9 @@ func AllScopeNameValues() []ScopeName {
ScopeWorkspaceProxyDelete,
ScopeWorkspaceProxyRead,
ScopeWorkspaceProxyUpdate,
ScopeWorkspaceSecretCreate,
ScopeWorkspaceSecretRead,
ScopeWorkspaceSecretReadSecret,
ScopeWorkspaceSecretUpdate,
}
}
+5
View File
@@ -265,6 +265,11 @@ const (
APIKeyScopeWorkspaceProxyDelete APIKeyScope = "workspace_proxy:delete"
APIKeyScopeWorkspaceProxyRead APIKeyScope = "workspace_proxy:read"
APIKeyScopeWorkspaceProxyUpdate APIKeyScope = "workspace_proxy:update"
APIKeyScopeWorkspaceSecretAll APIKeyScope = "workspace_secret:*"
APIKeyScopeWorkspaceSecretCreate APIKeyScope = "workspace_secret:create"
APIKeyScopeWorkspaceSecretRead APIKeyScope = "workspace_secret:read"
APIKeyScopeWorkspaceSecretReadSecret APIKeyScope = "workspace_secret:read_secret"
APIKeyScopeWorkspaceSecretUpdate APIKeyScope = "workspace_secret:update"
)
// PublicAPIKeyScopes lists all public low-level API key scopes.
+3
View File
@@ -59,6 +59,7 @@ const (
ResourceWorkspaceBuildOrchestration RBACResource = "workspace_build_orchestration"
ResourceWorkspaceDormant RBACResource = "workspace_dormant"
ResourceWorkspaceProxy RBACResource = "workspace_proxy"
ResourceWorkspaceSecret RBACResource = "workspace_secret"
)
type RBACAction string
@@ -72,6 +73,7 @@ const (
ActionDeleteAgent RBACAction = "delete_agent"
ActionRead RBACAction = "read"
ActionReadPersonal RBACAction = "read_personal"
ActionReadSecret RBACAction = "read_secret"
ActionSSH RBACAction = "ssh"
ActionShare RBACAction = "share"
ActionUnassign RBACAction = "unassign"
@@ -142,4 +144,5 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
ResourceWorkspaceBuildOrchestration: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionShare, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate, ActionUpdateAgent},
ResourceWorkspaceProxy: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceWorkspaceSecret: {ActionCreate, ActionRead, ActionReadSecret, ActionUpdate},
}
+3
View File
@@ -72,6 +72,7 @@ Each scope below grants the permissions in its table.
| `organization_member` | `read` |
| `template` | `read`, `use` |
| `workspace` | `create`, `read`, `start`, `stop`, `update` |
| `workspace_secret` | `create` |
### `coder:workspaces.delete`
@@ -80,6 +81,7 @@ Each scope below grants the permissions in its table.
| `organization_member` | `read` |
| `template` | `read`, `use` |
| `workspace` | `delete`, `read` |
| `workspace_secret` | `create` |
### `coder:workspaces.operate`
@@ -88,6 +90,7 @@ Each scope below grants the permissions in its table.
| `organization_member` | `read` |
| `template` | `read` |
| `workspace` | `read`, `start`, `stop`, `update` |
| `workspace_secret` | `create` |
## Low-level scopes
+20 -20
View File
@@ -198,10 +198,10 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` |
| Property | Value(s) |
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `read_secret`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy`, `workspace_secret` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -331,10 +331,10 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` |
| Property | Value(s) |
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `read_secret`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy`, `workspace_secret` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -464,10 +464,10 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` |
| Property | Value(s) |
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `read_secret`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy`, `workspace_secret` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -559,10 +559,10 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` |
| Property | Value(s) |
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `read_secret`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy`, `workspace_secret` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -965,9 +965,9 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` |
| Property | Value(s) |
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `read_secret`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy`, `workspace_secret` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+9 -9
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -819,11 +819,11 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` |
| `login_type` | `github`, `oidc`, `password`, `token` |
| `scope` | `all`, `application_connect` |
| Property | Value(s) |
|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `organization_skill`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy`, `workspace_secret` |
| `login_type` | `github`, `oidc`, `password`, `token` |
| `scope` | `all`, `application_connect` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+7
View File
@@ -312,4 +312,11 @@ export const RBACResourceActions: Partial<
read: "read and use a workspace proxy",
update: "update a workspace proxy",
},
workspace_secret: {
create: "set a workspace secret on a workspace build",
read: "read workspace secret metadata, never the value",
read_secret:
"read decrypted workspace secret values, granted only to the workspace secret manager actor",
update: "clear or re-encrypt workspace secret values",
},
};
+15 -1
View File
@@ -962,6 +962,11 @@ export type APIKeyScope =
| "workspace_proxy:read"
| "workspace_proxy:update"
| "workspace:read"
| "workspace_secret:*"
| "workspace_secret:create"
| "workspace_secret:read"
| "workspace_secret:read_secret"
| "workspace_secret:update"
| "workspace:share"
| "workspace:ssh"
| "workspace:start"
@@ -1225,6 +1230,11 @@ export const APIKeyScopes: APIKeyScope[] = [
"workspace_proxy:read",
"workspace_proxy:update",
"workspace:read",
"workspace_secret:*",
"workspace_secret:create",
"workspace_secret:read",
"workspace_secret:read_secret",
"workspace_secret:update",
"workspace:share",
"workspace:ssh",
"workspace:start",
@@ -8794,6 +8804,7 @@ export type RBACAction =
| "delete_agent"
| "read"
| "read_personal"
| "read_secret"
| "ssh"
| "share"
| "unassign"
@@ -8814,6 +8825,7 @@ export const RBACActions: RBACAction[] = [
"delete_agent",
"read",
"read_personal",
"read_secret",
"ssh",
"share",
"unassign",
@@ -8882,7 +8894,8 @@ export type RBACResource =
| "workspace_agent_resource_monitor"
| "workspace_build_orchestration"
| "workspace_dormant"
| "workspace_proxy";
| "workspace_proxy"
| "workspace_secret";
export const RBACResources: RBACResource[] = [
"ai_gateway_key",
@@ -8940,6 +8953,7 @@ export const RBACResources: RBACResource[] = [
"workspace_build_orchestration",
"workspace_dormant",
"workspace_proxy",
"workspace_secret",
];
// From codersdk/deployment.go