mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
Generated
+18
-4
@@ -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": {
|
||||
|
||||
Generated
+18
-4
@@ -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": {
|
||||
|
||||
Generated
+2
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+103
@@ -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()
|
||||
|
||||
Generated
+109
-1
@@ -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
@@ -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'
|
||||
);
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+89
-1
@@ -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"`
|
||||
|
||||
Generated
+31
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+331
@@ -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;
|
||||
Generated
+4
@@ -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);
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
Generated
+20
-20
@@ -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).
|
||||
|
||||
Generated
+9
-9
File diff suppressed because one or more lines are too long
Generated
+5
-5
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+15
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user