feat: add the organization skills data model (#30609)

## Stack

Part of
[CODAGT-1341](https://linear.app/codercom/issue/CODAGT-1341/support-organization-level-skills-in-coder-agents).
This stack adds organization skills to Coder Agents: admins publish a
skill once per organization, share it with groups or members, and chats
in that organization load it next to personal and workspace skills. It
replaces the 21-PR draft stack #30584 to #30604 with five PRs, bottom to
top:

1. **#30609** data model: one migration, queries, RBAC and dbauthz (this
PR)
2. #30610 organization skill API routes and the scope-neutral SDK
3. #30611 sharing ACL routes
4. #30612 settings UI: shared skills table, admin page, member section
5. #30613 chats: chatd loading, `/` menu, docs

## What changes

This PR adds the data model and authorization for organization skills.
Personal skills keep working unchanged: `git diff origin/main --
codersdk site` shows only generated RBAC and API key scope additions
(`apikey_scopes_gen.go`, `rbacresources_gen.go`,
`rbacresourcesGenerated.ts`, the RBAC and scope unions in
`typesGenerated.ts`) and `codersdk.ResourceTypeOrganizationSkill` with
its friendly string. No personal route, SDK type or site component
changes, and `coderd/userskills_test.go` changes only by mechanical
constant renames.

**One migration, `000621_skills`** (please review it closely):

- renames `user_skills` to `skills` with its constraints, index and
triggers, and recreates the cap function and
`delete_deleted_user_resources`, whose PL/pgSQL bodies name the table;
- adds `organization_id` and `project_id` (`ON DELETE CASCADE`),
`enabled` (default true), and `group_acl` / `user_acl` (`jsonb`, default
`{}`), with CHECKs for exactly one owner, ACLs that are JSON objects,
and ACLs that stay empty unless an organization owns the row, plus
partial unique name indexes per owner kind;
- replaces the cap trigger with `enforce_skills_per_owner_limit()`: 100
rows per owner, disabled rows included, locking the owner row `FOR NO
KEY UPDATE` so unrelated foreign key checks are not blocked;
- adds a `BEFORE DELETE` trigger on `organization_members` that removes
a departing member from `user_acl` on that organization's skills, so
rejoining doesn't restore a direct grant;
- adds the `organization_skill` `resource_type` and `api_key_scope` enum
values last; nothing in the migration uses them.

The down migration undoes the three old migrations in reverse order: it
drops organization and project rows and restores `user_skills`, its
names, the cap function and `user_id NOT NULL`.

Authorization: a new RBAC resource `organization_skill` (create, read,
update, delete, share). Owner and org admin get it; site and org
auditors get read; members read only through the skill's ACL, which
applies only to members of its organization. `Skill.RBACObject()`
returns the organization object with both ACLs; project rows stay denied
to everyone. New sqlc queries cover organization insert, get, list
(SQL-filtered through `GetAuthorizedOrganizationSkillMetadata` and a
`regosql` converter), update and delete, with dbauthz wrappers and the
audit table entries. No routes yet.

## Commits

The first commit reproduces the reviewed code from the old stack; each
later commit is a labeled trim or fix.

- `baa3150e3b7` feat: add the organization skills data model.
Reproduction: every non-site file this commit changes equals the
reviewed old `authz` head `1e16b3004c4`, except that the old stack's
migrations 000615, 000616 and 000617 are merged into one migration (then
`000615_skills`, now `000621_skills`) with one fixture
(`testdata/fixtures/000621_skills.up.sql`) and one migration test (now
`testMigration000621Skills`). At that commit, `dump.sql` was
byte-identical to the old head's.
- `1b84e83463a` test(coderd/database): keep only the SQL paths of the
org skill matrix. `TestOrganizationSkillAuthorization` becomes
`TestOrganizationSkillQueries` instead of being deleted, because
coverage showed it is this PR's only run of the
`GetAuthorizedOrganizationSkillMetadata` row scan (80% to 65% without
it) and of the organization update and delete queries (100% to 0%).
Swapping `group_acl` and `user_acl` in the converter makes
`GrantedMember` fail. The removed cases are decided by
`TestRolePermissions`, `TestSkillRBACObjectOrganizationACL` and the
dbauthz method suite.
- `e2931b412e9` test(coderd/database): fold the skill cap cases into
existing coverage. The user case is covered by `TestUserSkillLimit` and
`TestUserSkillLimitConcurrentCreates`; the organization case folds into
`ConcurrentOrganizationInsertsHonorCap`, which now also checks that
disabled rows count and another organization is unaffected (mutation
checked); the project case stays.
- `be62aea9021` test(coderd/rbac): drop
`TestOrganizationSkillReadScope`, covered by `TestExternalScopeNames`
and the generated scope file.
- `0c275ba37de` merges main at `454a2fcc697`, which took 000615 to
000618: it renumbers the migration to 000619 and resolves `dump.sql`,
`check_constraint.go` and the generated API docs. `8f2b4b87ee2` merges
main at `ad85646164e`, which took 000619: it renumbers the migration to
000620.
- `5248d56b64f` merges main at `e7b76872098`, which took 000620
(`000620_audit_chat_project_instructions_resource_type`): it renumbers
the migration, fixture and migration test to `000621_skills` (step
`Migration000621Skills`, version 621), and resolves
`enterprise/audit/table.go` (keeping main's
`AuditableChatProjectInstructions` entries and the `Skill` entry),
`dump.sql` and `models.go` (the `resource_type` enum gets
`chat_project_instructions`, then `organization_skill`) and the
regenerated `schemas.md`. The up and down SQL are unchanged by every
renumbering.

## Size

Production +549/-67, tests +728/-33, generated +881/-314; total
+2158/-414. This is above the 500-line reviewer-friendly limit on
purpose: Mike asked for 4 to 5 PRs instead of the 21 small ones this
stack replaces.

## Replaces

#30584 (rename `user_skills` to `skills`), #30585 (skill owners), #30586
(organization skill authorization).

## Validation

- Migration test: up keeps the existing personal row and its ID; down
restores the `user_skills` names, cap function and `NOT NULL`, and drops
organization rows; soft-deleting a user removes their skills after up,
after down, and after re-up.
- `go test` passed on `be62aea9021`, before the main merges, for
`coderd` (skill tests), `coderd/database`, `dbauthz`, `migrations`,
`gentest`, `coderd/rbac/...`, `coderd/x/chatd`, `coderd/x/skills`,
`enterprise/audit` and `enterprise/coderd`; `make lint/check-scopes`
passed; the four personal skills Vitest files pass unchanged.
- On `5248d56b64f`: build, vet, `TestMigrate`, `TestMigrationChain`, the
`migrations`, `database`, `dbauthz`, `gentest`, `rbac` and
`enterprise/audit` packages pass; `make gen` run twice is clean, and
`make pre-commit` passes.
- Remote UAT round 7 through Coder Agents passed on the whole stack; see
#30613's Validation for scope and findings.

> Xum opened this PR on behalf of @ibetitsmike.
This commit is contained in:
Michael Suchacz
2026-10-13 15:51:31 +00:00
committed by GitHub
parent e7b7687209
commit b67587c875
50 changed files with 2158 additions and 414 deletions
+16
View File
@@ -19521,6 +19521,12 @@ const docTemplate = `{
"organization_member:delete",
"organization_member:read",
"organization_member:update",
"organization_skill:*",
"organization_skill:create",
"organization_skill:delete",
"organization_skill:read",
"organization_skill:share",
"organization_skill:update",
"prebuilt_workspace:*",
"prebuilt_workspace:delete",
"prebuilt_workspace:update",
@@ -19777,6 +19783,12 @@ const docTemplate = `{
"APIKeyScopeOrganizationMemberDelete",
"APIKeyScopeOrganizationMemberRead",
"APIKeyScopeOrganizationMemberUpdate",
"APIKeyScopeOrganizationSkillAll",
"APIKeyScopeOrganizationSkillCreate",
"APIKeyScopeOrganizationSkillDelete",
"APIKeyScopeOrganizationSkillRead",
"APIKeyScopeOrganizationSkillShare",
"APIKeyScopeOrganizationSkillUpdate",
"APIKeyScopePrebuiltWorkspaceAll",
"APIKeyScopePrebuiltWorkspaceDelete",
"APIKeyScopePrebuiltWorkspaceUpdate",
@@ -29020,6 +29032,7 @@ const docTemplate = `{
"oauth2_app_secret",
"organization",
"organization_member",
"organization_skill",
"prebuilt_workspace",
"provisioner_daemon",
"provisioner_jobs",
@@ -29076,6 +29089,7 @@ const docTemplate = `{
"ResourceOauth2AppSecret",
"ResourceOrganization",
"ResourceOrganizationMember",
"ResourceOrganizationSkill",
"ResourcePrebuiltWorkspace",
"ResourceProvisionerDaemon",
"ResourceProvisionerJobs",
@@ -29319,6 +29333,7 @@ const docTemplate = `{
"chat_automation",
"user_secret",
"user_skill",
"organization_skill",
"chat_instruction_settings",
"chat_operational_settings",
"chat_organization_system_prompt",
@@ -29367,6 +29382,7 @@ const docTemplate = `{
"ResourceTypeChatAutomation",
"ResourceTypeUserSecret",
"ResourceTypeUserSkill",
"ResourceTypeOrganizationSkill",
"ResourceTypeChatInstructionSettings",
"ResourceTypeChatOperationalSettings",
"ResourceTypeChatOrganizationSystemPrompt",
+16
View File
@@ -17503,6 +17503,12 @@
"organization_member:delete",
"organization_member:read",
"organization_member:update",
"organization_skill:*",
"organization_skill:create",
"organization_skill:delete",
"organization_skill:read",
"organization_skill:share",
"organization_skill:update",
"prebuilt_workspace:*",
"prebuilt_workspace:delete",
"prebuilt_workspace:update",
@@ -17759,6 +17765,12 @@
"APIKeyScopeOrganizationMemberDelete",
"APIKeyScopeOrganizationMemberRead",
"APIKeyScopeOrganizationMemberUpdate",
"APIKeyScopeOrganizationSkillAll",
"APIKeyScopeOrganizationSkillCreate",
"APIKeyScopeOrganizationSkillDelete",
"APIKeyScopeOrganizationSkillRead",
"APIKeyScopeOrganizationSkillShare",
"APIKeyScopeOrganizationSkillUpdate",
"APIKeyScopePrebuiltWorkspaceAll",
"APIKeyScopePrebuiltWorkspaceDelete",
"APIKeyScopePrebuiltWorkspaceUpdate",
@@ -26662,6 +26674,7 @@
"oauth2_app_secret",
"organization",
"organization_member",
"organization_skill",
"prebuilt_workspace",
"provisioner_daemon",
"provisioner_jobs",
@@ -26718,6 +26731,7 @@
"ResourceOauth2AppSecret",
"ResourceOrganization",
"ResourceOrganizationMember",
"ResourceOrganizationSkill",
"ResourcePrebuiltWorkspace",
"ResourceProvisionerDaemon",
"ResourceProvisionerJobs",
@@ -26951,6 +26965,7 @@
"chat_automation",
"user_secret",
"user_skill",
"organization_skill",
"chat_instruction_settings",
"chat_operational_settings",
"chat_organization_system_prompt",
@@ -26999,6 +27014,7 @@
"ResourceTypeChatAutomation",
"ResourceTypeUserSecret",
"ResourceTypeUserSkill",
"ResourceTypeOrganizationSkill",
"ResourceTypeChatInstructionSettings",
"ResourceTypeChatOperationalSettings",
"ResourceTypeChatOrganizationSystemPrompt",
+1 -1
View File
@@ -46,7 +46,7 @@ type Auditable interface {
database.AuditableGroupAIBudget |
database.AuditableUserAIBudgetOverride |
database.UserSecret |
database.UserSkill |
database.Skill |
database.ChatInstructionSettings |
database.ChatOperationalSettings |
database.ChatOrganizationSystemPrompt |
+4 -4
View File
@@ -167,7 +167,7 @@ func ResourceTarget[T Auditable](tgt T) string {
return cmp.Or(typed.DisplayName, typed.Slug, typed.ID.String())
case database.UserSecret:
return typed.Name
case database.UserSkill:
case database.Skill:
return typed.Name
case database.ChatInstructionSettings:
return typed.Name
@@ -285,7 +285,7 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.ID
case database.UserSecret:
return typed.ID
case database.UserSkill:
case database.Skill:
return typed.ID
case database.ChatInstructionSettings:
// Fixed ID per setting; see ChatInstructionSettings IDs.
@@ -378,7 +378,7 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeMCPServerConfig
case database.UserSecret:
return database.ResourceTypeUserSecret
case database.UserSkill:
case database.Skill:
return database.ResourceTypeUserSkill
case database.ChatInstructionSettings:
return database.ResourceTypeChatInstructionSettings
@@ -484,7 +484,7 @@ func ResourceRequiresOrgID[T Auditable]() bool {
case database.UserSecret:
// User secrets are global to the user across organizations.
return false
case database.UserSkill:
case database.Skill:
// User skills are global to the user across organizations.
return false
case database.ChatInstructionSettings:
+8 -4
View File
@@ -66,6 +66,14 @@ const (
CheckOauth2ProviderAppsClientTypeCheck CheckConstraint = "oauth2_provider_apps_client_type_check" // oauth2_provider_apps
CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs
CheckNatsPortValidTcp CheckConstraint = "nats_port_valid_tcp" // replicas
CheckSkillsAclOnlyOnOrganizationSkills CheckConstraint = "skills_acl_only_on_organization_skills" // skills
CheckSkillsContentSize CheckConstraint = "skills_content_size" // skills
CheckSkillsDescriptionSize CheckConstraint = "skills_description_size" // skills
CheckSkillsGroupAclIsObject CheckConstraint = "skills_group_acl_is_object" // skills
CheckSkillsNameFormat CheckConstraint = "skills_name_format" // skills
CheckSkillsNameSize CheckConstraint = "skills_name_size" // skills
CheckSkillsSingleOwner CheckConstraint = "skills_single_owner" // skills
CheckSkillsUserAclIsObject CheckConstraint = "skills_user_acl_is_object" // skills
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
@@ -73,10 +81,6 @@ const (
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills
CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills
CheckUserSkillsNameFormat CheckConstraint = "user_skills_name_format" // user_skills
CheckUserSkillsNameSize CheckConstraint = "user_skills_name_size" // user_skills
CheckWorkspaceAgentScriptsDependenciesIsArray CheckConstraint = "workspace_agent_scripts_dependencies_is_array" // workspace_agent_scripts
CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents
CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents
+1 -1
View File
@@ -2427,7 +2427,7 @@ func UserSecrets(secrets []database.ListUserSecretsRow) []codersdk.UserSecret {
}
// UserSkill converts a database UserSkill to an SDK UserSkill.
func UserSkill(skill database.UserSkill) codersdk.UserSkill {
func UserSkill(skill database.Skill) codersdk.UserSkill {
return codersdk.UserSkill{
UserSkillMetadata: codersdk.UserSkillMetadata{
ID: skill.ID,
+48 -8
View File
@@ -2623,6 +2623,13 @@ func (q *querier) DeleteOrganizationMember(ctx context.Context, arg database.Del
}, q.db.DeleteOrganizationMember)(ctx, arg)
}
func (q *querier) DeleteOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.DeleteOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
fetch := func(ctx context.Context, arg database.DeleteOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
return q.db.GetOrganizationSkillByOrganizationIDAndName(ctx, database.GetOrganizationSkillByOrganizationIDAndNameParams(arg))
}
return fetchAndQuery(q.log, q.auth, policy.ActionDelete, fetch, q.db.DeleteOrganizationSkillByOrganizationIDAndName)(ctx, arg)
}
func (q *querier) DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error {
return deleteQ(q.log, q.auth, q.db.GetProvisionerKeyByID, q.db.DeleteProvisionerKey)(ctx, id)
}
@@ -2742,10 +2749,10 @@ func (q *querier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg datab
return q.db.DeleteUserSecretByUserIDAndName(ctx, arg)
}
func (q *querier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (q *querier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.Skill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionDelete, obj); err != nil {
return database.UserSkill{}, err
return database.Skill{}, err
}
return q.db.DeleteUserSkillByUserIDAndName(ctx, arg)
}
@@ -4644,6 +4651,10 @@ func (q *querier) GetOrganizationResourceCountByID(ctx context.Context, organiza
return q.db.GetOrganizationResourceCountByID(ctx, organizationID)
}
func (q *querier) GetOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.GetOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
return fetch(q.log, q.auth, q.db.GetOrganizationSkillByOrganizationIDAndName)(ctx, arg)
}
func (q *querier) GetOrganizations(ctx context.Context, args database.GetOrganizationsParams) ([]database.Organization, error) {
fetch := func(ctx context.Context, _ interface{}) ([]database.Organization, error) {
return q.db.GetOrganizations(ctx, args)
@@ -5605,10 +5616,10 @@ func (q *querier) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.U
return q.db.GetUserShellToolDisplayMode(ctx, userID)
}
func (q *querier) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (q *querier) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.Skill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil {
return database.UserSkill{}, err
return database.Skill{}, err
}
return q.db.GetUserSkillByUserIDAndName(ctx, arg)
}
@@ -6620,6 +6631,13 @@ func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.Ins
return insert(q.log, q.auth, obj, q.db.InsertOrganizationMember)(ctx, arg)
}
func (q *querier) InsertOrganizationSkill(ctx context.Context, arg database.InsertOrganizationSkillParams) (database.Skill, error) {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOrganizationSkill.InOrg(arg.OrganizationID)); err != nil {
return database.Skill{}, err
}
return q.db.InsertOrganizationSkill(ctx, arg)
}
func (q *querier) InsertPreset(ctx context.Context, arg database.InsertPresetParams) (database.TemplateVersionPreset, error) {
err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceTemplate)
if err != nil {
@@ -6784,10 +6802,10 @@ func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLin
return q.db.InsertUserLink(ctx, arg)
}
func (q *querier) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
func (q *querier) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.Skill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
return database.UserSkill{}, err
return database.Skill{}, err
}
return q.db.InsertUserSkill(ctx, arg)
}
@@ -7181,6 +7199,14 @@ func (q *querier) ListOrganizationAISpendUsers(ctx context.Context, arg database
return q.db.ListOrganizationAISpendUsers(ctx, arg)
}
func (q *querier) ListOrganizationSkillMetadataByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.ListOrganizationSkillMetadataByOrganizationIDRow, error) {
prepared, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceOrganizationSkill.Type)
if err != nil {
return nil, xerrors.Errorf("prepare sql filter: %w", err)
}
return q.db.GetAuthorizedOrganizationSkillMetadata(ctx, organizationID, prepared)
}
func (q *querier) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListProvisionerKeysByOrganization)(ctx, organizationID)
}
@@ -8190,6 +8216,16 @@ func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg databas
return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, deleteF)(ctx, arg.ID)
}
func (q *querier) UpdateOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.UpdateOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
fetch := func(ctx context.Context, arg database.UpdateOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
return q.db.GetOrganizationSkillByOrganizationIDAndName(ctx, database.GetOrganizationSkillByOrganizationIDAndNameParams{
OrganizationID: arg.OrganizationID,
Name: arg.Name,
})
}
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganizationSkillByOrganizationIDAndName)(ctx, arg)
}
func (q *querier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
fetch := func(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
return q.db.GetOrganizationByID(ctx, arg.ID)
@@ -8742,10 +8778,10 @@ func (q *querier) UpdateUserShellToolDisplayMode(ctx context.Context, arg databa
return q.db.UpdateUserShellToolDisplayMode(ctx, arg)
}
func (q *querier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (q *querier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.Skill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil {
return database.UserSkill{}, err
return database.Skill{}, err
}
return q.db.UpdateUserSkillByUserIDAndName(ctx, arg)
}
@@ -9767,3 +9803,7 @@ func (q *querier) GetAuthorizedChatModelConfigs(ctx context.Context, organizatio
func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) {
return q.db.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared)
}
func (q *querier) GetAuthorizedOrganizationSkillMetadata(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ListOrganizationSkillMetadataByOrganizationIDRow, error) {
return q.db.GetAuthorizedOrganizationSkillMetadata(ctx, organizationID, prepared)
}
+70 -6
View File
@@ -6855,7 +6855,7 @@ func (s *MethodTestSuite) TestUserSecrets() {
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{})
skill := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID})
skill := testutil.Fake(s.T(), faker, database.Skill{UserID: uuid.NullUUID{UUID: user.ID, Valid: true}})
arg := database.GetUserSkillByUserIDAndNameParams{UserID: user.ID, Name: skill.Name}
dbm.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), arg).Return(skill, nil).AnyTimes()
check.Args(arg).
@@ -6864,7 +6864,7 @@ func (s *MethodTestSuite) TestUserSkills() {
}))
s.Run("ListUserSkillMetadataByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
row := testutil.Fake(s.T(), faker, database.ListUserSkillMetadataByUserIDRow{UserID: user.ID})
row := testutil.Fake(s.T(), faker, database.ListUserSkillMetadataByUserIDRow{UserID: uuid.NullUUID{UUID: user.ID, Valid: true}})
dbm.EXPECT().ListUserSkillMetadataByUserID(gomock.Any(), user.ID).Return([]database.ListUserSkillMetadataByUserIDRow{row}, nil).AnyTimes()
check.Args(user.ID).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionRead).
@@ -6877,9 +6877,9 @@ func (s *MethodTestSuite) TestUserSkills() {
UserID: user.ID,
Name: "test",
}
ret := testutil.Fake(s.T(), faker, database.UserSkill{
ret := testutil.Fake(s.T(), faker, database.Skill{
ID: arg.ID,
UserID: user.ID,
UserID: uuid.NullUUID{UUID: user.ID, Valid: true},
Name: arg.Name,
})
dbm.EXPECT().InsertUserSkill(gomock.Any(), arg).Return(ret, nil).AnyTimes()
@@ -6890,7 +6890,7 @@ func (s *MethodTestSuite) TestUserSkills() {
s.Run("UpdateUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserSkillByUserIDAndNameParams{UserID: user.ID, Name: "test"}
updated := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID, Name: arg.Name})
updated := testutil.Fake(s.T(), faker, database.Skill{UserID: uuid.NullUUID{UUID: user.ID, Valid: true}, Name: arg.Name})
dbm.EXPECT().UpdateUserSkillByUserIDAndName(gomock.Any(), arg).Return(updated, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionUpdate).
@@ -6899,7 +6899,7 @@ func (s *MethodTestSuite) TestUserSkills() {
s.Run("DeleteUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
arg := database.DeleteUserSkillByUserIDAndNameParams{UserID: user.ID, Name: "test"}
deleted := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID, Name: arg.Name})
deleted := testutil.Fake(s.T(), faker, database.Skill{UserID: uuid.NullUUID{UUID: user.ID, Valid: true}, Name: arg.Name})
dbm.EXPECT().DeleteUserSkillByUserIDAndName(gomock.Any(), arg).Return(deleted, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionDelete).
@@ -6907,6 +6907,70 @@ func (s *MethodTestSuite) TestUserSkills() {
}))
}
func (s *MethodTestSuite) TestOrganizationSkills() {
orgSkill := func(orgID uuid.UUID, name string) database.Skill {
return database.Skill{
ID: uuid.New(),
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
Name: name,
GroupACL: database.ChatACL{orgID.String(): {Permissions: []policy.Action{policy.ActionRead}}},
}
}
s.Run("InsertOrganizationSkill", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.InsertOrganizationSkillParams{ID: uuid.New(), OrganizationID: uuid.New(), Name: "test"}
ret := orgSkill(arg.OrganizationID, arg.Name)
dbm.EXPECT().InsertOrganizationSkill(gomock.Any(), arg).Return(ret, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceOrganizationSkill.InOrg(arg.OrganizationID), policy.ActionCreate).
Returns(ret)
}))
s.Run("GetOrganizationSkillByOrganizationIDAndName", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetOrganizationSkillByOrganizationIDAndNameParams{OrganizationID: uuid.New(), Name: "test"}
skill := orgSkill(arg.OrganizationID, arg.Name)
dbm.EXPECT().GetOrganizationSkillByOrganizationIDAndName(gomock.Any(), arg).Return(skill, nil).AnyTimes()
check.Args(arg).Asserts(skill, policy.ActionRead).Returns(skill)
}))
s.Run("ListOrganizationSkillMetadataByOrganizationID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
orgID := uuid.New()
rows := []database.ListOrganizationSkillMetadataByOrganizationIDRow{
testutil.Fake(s.T(), faker, database.ListOrganizationSkillMetadataByOrganizationIDRow{OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}}),
}
dbm.EXPECT().GetAuthorizedOrganizationSkillMetadata(gomock.Any(), orgID, gomock.Any()).Return(rows, nil).AnyTimes()
check.Args(orgID).Asserts().Returns(rows)
}))
s.Run("GetAuthorizedOrganizationSkillMetadata", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
orgID := uuid.New()
rows := []database.ListOrganizationSkillMetadataByOrganizationIDRow{
testutil.Fake(s.T(), faker, database.ListOrganizationSkillMetadataByOrganizationIDRow{OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}}),
}
dbm.EXPECT().GetAuthorizedOrganizationSkillMetadata(gomock.Any(), orgID, gomock.Any()).Return(rows, nil).AnyTimes()
check.Args(orgID, emptyPreparedAuthorized{}).Asserts().Returns(rows)
}))
s.Run("UpdateOrganizationSkillByOrganizationIDAndName", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.UpdateOrganizationSkillByOrganizationIDAndNameParams{
OrganizationID: uuid.New(),
Name: "test",
Enabled: sql.NullBool{Bool: false, Valid: true},
}
skill := orgSkill(arg.OrganizationID, arg.Name)
updated := skill
updated.Enabled = false
dbm.EXPECT().GetOrganizationSkillByOrganizationIDAndName(gomock.Any(), database.GetOrganizationSkillByOrganizationIDAndNameParams{
OrganizationID: arg.OrganizationID,
Name: arg.Name,
}).Return(skill, nil).AnyTimes()
dbm.EXPECT().UpdateOrganizationSkillByOrganizationIDAndName(gomock.Any(), arg).Return(updated, nil).AnyTimes()
check.Args(arg).Asserts(skill, policy.ActionUpdate).Returns(updated)
}))
s.Run("DeleteOrganizationSkillByOrganizationIDAndName", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.DeleteOrganizationSkillByOrganizationIDAndNameParams{OrganizationID: uuid.New(), Name: "test"}
skill := orgSkill(arg.OrganizationID, arg.Name)
dbm.EXPECT().GetOrganizationSkillByOrganizationIDAndName(gomock.Any(), database.GetOrganizationSkillByOrganizationIDAndNameParams(arg)).Return(skill, nil).AnyTimes()
dbm.EXPECT().DeleteOrganizationSkillByOrganizationIDAndName(gomock.Any(), arg).Return(skill, nil).AnyTimes()
check.Args(arg).Asserts(skill, policy.ActionDelete).Returns(skill)
}))
}
func (s *MethodTestSuite) TestUsageEvents() {
s.Run("InsertUsageEvent", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.InsertUsageEventParams{
+21
View File
@@ -1175,6 +1175,27 @@ func Organization(t testing.TB, db database.Store, orig database.Organization) d
return org
}
// OrganizationSkill inserts a skill owned by seed.OrganizationID, which is
// required. Inserted skills are enabled.
func OrganizationSkill(t testing.TB, db database.Store, seed database.Skill) database.Skill {
t.Helper()
require.True(t, seed.OrganizationID.Valid, "organization skill requires an organization ID")
name := takeFirst(seed.Name, "skill-"+uuid.NewString())
description := takeFirst(seed.Description, "Organization skill fixture.")
skill, err := db.InsertOrganizationSkill(genCtx, database.InsertOrganizationSkillParams{
ID: takeFirst(seed.ID, uuid.New()),
OrganizationID: seed.OrganizationID.UUID,
Name: name,
Description: description,
Content: takeFirst(seed.Content, fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n\nFixture instructions.\n", name, description)),
GroupACL: seed.GroupACL,
UserACL: seed.UserACL,
})
require.NoError(t, err, "insert organization skill")
return skill
}
func OrganizationMember(t testing.TB, db database.Store, orig database.OrganizationMember) database.OrganizationMember {
mem, err := db.InsertOrganizationMember(genCtx, database.InsertOrganizationMemberParams{
OrganizationID: takeFirst(orig.OrganizationID, uuid.New()),
+52 -4
View File
@@ -872,6 +872,14 @@ func (m queryMetricsStore) DeleteOrganizationMember(ctx context.Context, arg dat
return r0
}
func (m queryMetricsStore) DeleteOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.DeleteOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.DeleteOrganizationSkillByOrganizationIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteOrganizationSkillByOrganizationIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteOrganizationSkillByOrganizationIDAndName").Inc()
return r0, r1
}
func (m queryMetricsStore) DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteProvisionerKey(ctx, id)
@@ -976,7 +984,7 @@ func (m queryMetricsStore) DeleteUserSecretByUserIDAndName(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (m queryMetricsStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.DeleteUserSkillByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
@@ -2808,6 +2816,14 @@ func (m queryMetricsStore) GetOrganizationResourceCountByID(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) GetOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.GetOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.GetOrganizationSkillByOrganizationIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("GetOrganizationSkillByOrganizationIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetOrganizationSkillByOrganizationIDAndName").Inc()
return r0, r1
}
func (m queryMetricsStore) GetOrganizations(ctx context.Context, arg database.GetOrganizationsParams) ([]database.Organization, error) {
start := time.Now()
r0, r1 := m.s.GetOrganizations(ctx, arg)
@@ -3632,7 +3648,7 @@ func (m queryMetricsStore) GetUserShellToolDisplayMode(ctx context.Context, user
return r0, r1
}
func (m queryMetricsStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (m queryMetricsStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.GetUserSkillByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("GetUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
@@ -4568,6 +4584,14 @@ func (m queryMetricsStore) InsertOrganizationMember(ctx context.Context, arg dat
return r0, r1
}
func (m queryMetricsStore) InsertOrganizationSkill(ctx context.Context, arg database.InsertOrganizationSkillParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.InsertOrganizationSkill(ctx, arg)
m.queryLatencies.WithLabelValues("InsertOrganizationSkill").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertOrganizationSkill").Inc()
return r0, r1
}
func (m queryMetricsStore) InsertPreset(ctx context.Context, arg database.InsertPresetParams) (database.TemplateVersionPreset, error) {
start := time.Now()
r0, r1 := m.s.InsertPreset(ctx, arg)
@@ -4728,7 +4752,7 @@ func (m queryMetricsStore) InsertUserLink(ctx context.Context, arg database.Inse
return r0, r1
}
func (m queryMetricsStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
func (m queryMetricsStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.InsertUserSkill(ctx, arg)
m.queryLatencies.WithLabelValues("InsertUserSkill").Observe(time.Since(start).Seconds())
@@ -5024,6 +5048,14 @@ func (m queryMetricsStore) ListOrganizationAISpendUsers(ctx context.Context, arg
return r0, r1
}
func (m queryMetricsStore) ListOrganizationSkillMetadataByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.ListOrganizationSkillMetadataByOrganizationIDRow, error) {
start := time.Now()
r0, r1 := m.s.ListOrganizationSkillMetadataByOrganizationID(ctx, organizationID)
m.queryLatencies.WithLabelValues("ListOrganizationSkillMetadataByOrganizationID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListOrganizationSkillMetadataByOrganizationID").Inc()
return r0, r1
}
func (m queryMetricsStore) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) {
start := time.Now()
r0, r1 := m.s.ListProvisionerKeysByOrganization(ctx, organizationID)
@@ -5785,6 +5817,14 @@ func (m queryMetricsStore) UpdateOrganizationDeletedByID(ctx context.Context, ar
return r0
}
func (m queryMetricsStore) UpdateOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.UpdateOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.UpdateOrganizationSkillByOrganizationIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateOrganizationSkillByOrganizationIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateOrganizationSkillByOrganizationIDAndName").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
start := time.Now()
r0, r1 := m.s.UpdateOrganizationWorkspaceSharingSettings(ctx, arg)
@@ -6153,7 +6193,7 @@ func (m queryMetricsStore) UpdateUserShellToolDisplayMode(ctx context.Context, a
return r0, r1
}
func (m queryMetricsStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (m queryMetricsStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.Skill, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserSkillByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
@@ -7088,3 +7128,11 @@ func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, or
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedMCPServerConfigs").Inc()
return r0, r1
}
func (m queryMetricsStore) GetAuthorizedOrganizationSkillMetadata(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ListOrganizationSkillMetadataByOrganizationIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetAuthorizedOrganizationSkillMetadata(ctx, organizationID, prepared)
m.queryLatencies.WithLabelValues("GetAuthorizedOrganizationSkillMetadata").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedOrganizationSkillMetadata").Inc()
return r0, r1
}
+98 -8
View File
@@ -1499,6 +1499,21 @@ func (mr *MockStoreMockRecorder) DeleteOrganizationMember(ctx, arg any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganizationMember", reflect.TypeOf((*MockStore)(nil).DeleteOrganizationMember), ctx, arg)
}
// DeleteOrganizationSkillByOrganizationIDAndName mocks base method.
func (m *MockStore) DeleteOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.DeleteOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteOrganizationSkillByOrganizationIDAndName", ctx, arg)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteOrganizationSkillByOrganizationIDAndName indicates an expected call of DeleteOrganizationSkillByOrganizationIDAndName.
func (mr *MockStoreMockRecorder) DeleteOrganizationSkillByOrganizationIDAndName(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganizationSkillByOrganizationIDAndName", reflect.TypeOf((*MockStore)(nil).DeleteOrganizationSkillByOrganizationIDAndName), ctx, arg)
}
// DeleteProvisionerKey mocks base method.
func (m *MockStore) DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
@@ -1688,10 +1703,10 @@ func (mr *MockStoreMockRecorder) DeleteUserSecretByUserIDAndName(ctx, arg any) *
}
// DeleteUserSkillByUserIDAndName mocks base method.
func (m *MockStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (m *MockStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteUserSkillByUserIDAndName", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -2681,6 +2696,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedMCPServerConfigs(ctx, organization
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedMCPServerConfigs), ctx, organizationID, prepared)
}
// GetAuthorizedOrganizationSkillMetadata mocks base method.
func (m *MockStore) GetAuthorizedOrganizationSkillMetadata(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ListOrganizationSkillMetadataByOrganizationIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthorizedOrganizationSkillMetadata", ctx, organizationID, prepared)
ret0, _ := ret[0].([]database.ListOrganizationSkillMetadataByOrganizationIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAuthorizedOrganizationSkillMetadata indicates an expected call of GetAuthorizedOrganizationSkillMetadata.
func (mr *MockStoreMockRecorder) GetAuthorizedOrganizationSkillMetadata(ctx, organizationID, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedOrganizationSkillMetadata", reflect.TypeOf((*MockStore)(nil).GetAuthorizedOrganizationSkillMetadata), ctx, organizationID, prepared)
}
// GetAuthorizedTemplates mocks base method.
func (m *MockStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) {
m.ctrl.T.Helper()
@@ -5261,6 +5291,21 @@ func (mr *MockStoreMockRecorder) GetOrganizationResourceCountByID(ctx, organizat
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationResourceCountByID", reflect.TypeOf((*MockStore)(nil).GetOrganizationResourceCountByID), ctx, organizationID)
}
// GetOrganizationSkillByOrganizationIDAndName mocks base method.
func (m *MockStore) GetOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.GetOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOrganizationSkillByOrganizationIDAndName", ctx, arg)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOrganizationSkillByOrganizationIDAndName indicates an expected call of GetOrganizationSkillByOrganizationIDAndName.
func (mr *MockStoreMockRecorder) GetOrganizationSkillByOrganizationIDAndName(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationSkillByOrganizationIDAndName", reflect.TypeOf((*MockStore)(nil).GetOrganizationSkillByOrganizationIDAndName), ctx, arg)
}
// GetOrganizations mocks base method.
func (m *MockStore) GetOrganizations(ctx context.Context, arg database.GetOrganizationsParams) ([]database.Organization, error) {
m.ctrl.T.Helper()
@@ -6837,10 +6882,10 @@ func (mr *MockStoreMockRecorder) GetUserShellToolDisplayMode(ctx, userID any) *g
}
// GetUserSkillByUserIDAndName mocks base method.
func (m *MockStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (m *MockStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserSkillByUserIDAndName", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -8600,6 +8645,21 @@ func (mr *MockStoreMockRecorder) InsertOrganizationMember(ctx, arg any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertOrganizationMember", reflect.TypeOf((*MockStore)(nil).InsertOrganizationMember), ctx, arg)
}
// InsertOrganizationSkill mocks base method.
func (m *MockStore) InsertOrganizationSkill(ctx context.Context, arg database.InsertOrganizationSkillParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertOrganizationSkill", ctx, arg)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertOrganizationSkill indicates an expected call of InsertOrganizationSkill.
func (mr *MockStoreMockRecorder) InsertOrganizationSkill(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertOrganizationSkill", reflect.TypeOf((*MockStore)(nil).InsertOrganizationSkill), ctx, arg)
}
// InsertPreset mocks base method.
func (m *MockStore) InsertPreset(ctx context.Context, arg database.InsertPresetParams) (database.TemplateVersionPreset, error) {
m.ctrl.T.Helper()
@@ -8895,10 +8955,10 @@ func (mr *MockStoreMockRecorder) InsertUserLink(ctx, arg any) *gomock.Call {
}
// InsertUserSkill mocks base method.
func (m *MockStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
func (m *MockStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertUserSkill", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -9519,6 +9579,21 @@ func (mr *MockStoreMockRecorder) ListOrganizationAISpendUsers(ctx, arg any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationAISpendUsers", reflect.TypeOf((*MockStore)(nil).ListOrganizationAISpendUsers), ctx, arg)
}
// ListOrganizationSkillMetadataByOrganizationID mocks base method.
func (m *MockStore) ListOrganizationSkillMetadataByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.ListOrganizationSkillMetadataByOrganizationIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListOrganizationSkillMetadataByOrganizationID", ctx, organizationID)
ret0, _ := ret[0].([]database.ListOrganizationSkillMetadataByOrganizationIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListOrganizationSkillMetadataByOrganizationID indicates an expected call of ListOrganizationSkillMetadataByOrganizationID.
func (mr *MockStoreMockRecorder) ListOrganizationSkillMetadataByOrganizationID(ctx, organizationID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationSkillMetadataByOrganizationID", reflect.TypeOf((*MockStore)(nil).ListOrganizationSkillMetadataByOrganizationID), ctx, organizationID)
}
// ListProvisionerKeysByOrganization mocks base method.
func (m *MockStore) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) {
m.ctrl.T.Helper()
@@ -10946,6 +11021,21 @@ func (mr *MockStoreMockRecorder) UpdateOrganizationDeletedByID(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationDeletedByID), ctx, arg)
}
// UpdateOrganizationSkillByOrganizationIDAndName mocks base method.
func (m *MockStore) UpdateOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.UpdateOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateOrganizationSkillByOrganizationIDAndName", ctx, arg)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateOrganizationSkillByOrganizationIDAndName indicates an expected call of UpdateOrganizationSkillByOrganizationIDAndName.
func (mr *MockStoreMockRecorder) UpdateOrganizationSkillByOrganizationIDAndName(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationSkillByOrganizationIDAndName", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationSkillByOrganizationIDAndName), ctx, arg)
}
// UpdateOrganizationWorkspaceSharingSettings mocks base method.
func (m *MockStore) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
m.ctrl.T.Helper()
@@ -11613,10 +11703,10 @@ func (mr *MockStoreMockRecorder) UpdateUserShellToolDisplayMode(ctx, arg any) *g
}
// UpdateUserSkillByUserIDAndName mocks base method.
func (m *MockStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func (m *MockStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.Skill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserSkillByUserIDAndName", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret0, _ := ret[0].(database.Skill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
+113 -57
View File
@@ -294,7 +294,13 @@ CREATE TYPE api_key_scope AS ENUM (
'chat_project_memory:*',
'chat_project_memory:create',
'chat_project_memory:read',
'chat_project_memory:delete'
'chat_project_memory:delete',
'organization_skill:*',
'organization_skill:create',
'organization_skill:read',
'organization_skill:update',
'organization_skill:delete',
'organization_skill:share'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -637,7 +643,8 @@ CREATE TYPE resource_type AS ENUM (
'chat_automation',
'chat_project_memory',
'chat_organization_system_prompt',
'chat_project_instructions'
'chat_project_instructions',
'organization_skill'
);
CREATE TYPE shareable_workspace_owners AS ENUM (
@@ -991,10 +998,10 @@ BEGIN
DELETE FROM organization_members
WHERE user_id = OLD.id;
-- Remove their user_skills.
-- user_skills.user_id has ON DELETE CASCADE, but soft-delete
-- Remove their personal skills.
-- skills.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_skills
DELETE FROM skills
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
@@ -1020,6 +1027,21 @@ BEGIN
END;
$$;
CREATE FUNCTION delete_skill_user_acl_on_org_member_delete() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Drop the departing member's direct grants so adding them back to the
-- organization does not silently restore access.
UPDATE skills
SET
user_acl = user_acl - OLD.user_id::text,
updated_at = now()
WHERE organization_id = OLD.organization_id AND user_acl ? OLD.user_id::text;
RETURN OLD;
END;
$$;
CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -1040,6 +1062,45 @@ BEGIN
END;
$$;
CREATE FUNCTION enforce_skills_per_owner_limit() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
skill_count int;
skill_limit constant int := 100;
BEGIN
-- Serialize skill-cap checks per owner so concurrent inserts cannot all
-- observe the same pre-insert count and exceed the hard limit. FOR NO KEY
-- UPDATE does not block the KEY SHARE locks of unrelated foreign keys.
IF NEW.user_id IS NOT NULL THEN
PERFORM 1 FROM users WHERE id = NEW.user_id FOR NO KEY UPDATE;
SELECT count(*) INTO skill_count FROM skills WHERE user_id = NEW.user_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'user has reached the personal skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'skills_per_user_limit';
END IF;
ELSIF NEW.organization_id IS NOT NULL THEN
PERFORM 1 FROM organizations WHERE id = NEW.organization_id FOR NO KEY UPDATE;
SELECT count(*) INTO skill_count FROM skills WHERE organization_id = NEW.organization_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'organization has reached the skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'skills_per_organization_limit';
END IF;
ELSIF NEW.project_id IS NOT NULL THEN
PERFORM 1 FROM chat_projects WHERE id = NEW.project_id FOR NO KEY UPDATE;
SELECT count(*) INTO skill_count FROM skills WHERE project_id = NEW.project_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'project has reached the skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'skills_per_project_limit';
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -1117,32 +1178,6 @@ BEGIN
END;
$$;
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
skill_count int;
skill_limit constant int := 100;
BEGIN
-- Serialize skill-cap checks per user so concurrent inserts cannot all
-- observe the same pre-insert count and exceed the hard limit.
PERFORM 1
FROM users
WHERE id = NEW.user_id
FOR UPDATE;
SELECT count(*) INTO skill_count
FROM user_skills
WHERE user_id = NEW.user_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'user has reached the personal skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_skills_per_user_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION inhibit_enqueue_if_disabled() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -3135,6 +3170,29 @@ CREATE TABLE site_configs (
value text NOT NULL
);
CREATE TABLE skills (
id uuid NOT NULL,
user_id uuid,
name text NOT NULL,
description text DEFAULT ''::text NOT NULL,
content text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
organization_id uuid,
project_id uuid,
enabled boolean DEFAULT true NOT NULL,
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT skills_acl_only_on_organization_skills CHECK (((organization_id IS NOT NULL) OR ((group_acl = '{}'::jsonb) AND (user_acl = '{}'::jsonb)))),
CONSTRAINT skills_content_size CHECK ((octet_length(content) <= 65536)),
CONSTRAINT skills_description_size CHECK ((octet_length(description) <= 4096)),
CONSTRAINT skills_group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)),
CONSTRAINT skills_name_format CHECK ((name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text)),
CONSTRAINT skills_name_size CHECK ((octet_length(name) <= 256)),
CONSTRAINT skills_single_owner CHECK ((num_nonnulls(user_id, organization_id, project_id) = 1)),
CONSTRAINT skills_user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text))
);
CREATE UNLOGGED TABLE tailnet_coordinators (
id uuid NOT NULL,
heartbeat_at timestamp with time zone NOT NULL
@@ -3613,20 +3671,6 @@ CREATE TABLE user_secrets (
CONSTRAINT user_secrets_enabled_requires_target CHECK (((NOT enabled) OR (env_name <> ''::text) OR (file_path <> ''::text)))
);
CREATE TABLE user_skills (
id uuid NOT NULL,
user_id uuid NOT NULL,
name text NOT NULL,
description text DEFAULT ''::text NOT NULL,
content text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT user_skills_content_size CHECK ((octet_length(content) <= 65536)),
CONSTRAINT user_skills_description_size CHECK ((octet_length(description) <= 4096)),
CONSTRAINT user_skills_name_format CHECK ((name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text)),
CONSTRAINT user_skills_name_size CHECK ((octet_length(name) <= 256))
);
CREATE TABLE user_status_changes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid NOT NULL,
@@ -4615,6 +4659,9 @@ ALTER TABLE ONLY provisioner_keys
ALTER TABLE ONLY site_configs
ADD CONSTRAINT site_configs_key_key UNIQUE (key);
ALTER TABLE ONLY skills
ADD CONSTRAINT skills_pkey PRIMARY KEY (id);
ALTER TABLE ONLY tailnet_coordinators
ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id);
@@ -4696,9 +4743,6 @@ ALTER TABLE ONLY user_links
ALTER TABLE ONLY user_secrets
ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id);
ALTER TABLE ONLY user_skills
ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id);
ALTER TABLE ONLY user_status_changes
ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
@@ -5073,6 +5117,12 @@ COMMENT ON INDEX provisioner_jobs_worker_id_organization_id_completed_at_idx IS
CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text));
CREATE UNIQUE INDEX skills_organization_id_name_idx ON skills USING btree (organization_id, name) WHERE (organization_id IS NOT NULL);
CREATE UNIQUE INDEX skills_project_id_name_idx ON skills USING btree (project_id, name) WHERE (project_id IS NOT NULL);
CREATE UNIQUE INDEX skills_user_id_name_idx ON skills USING btree (user_id, name);
CREATE INDEX template_usage_stats_start_time_idx ON template_usage_stats USING btree (start_time DESC);
COMMENT ON INDEX template_usage_stats_start_time_idx IS 'Index for querying MAX(start_time).';
@@ -5091,8 +5141,6 @@ CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree
CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name);
CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name);
CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text));
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
@@ -5215,6 +5263,8 @@ CREATE TRIGGER trigger_delete_group_members_on_org_member_delete BEFORE DELETE O
CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_provider_app_tokens FOR EACH ROW EXECUTE FUNCTION delete_deleted_oauth2_provider_app_token_api_key();
CREATE TRIGGER trigger_delete_skill_user_acl_on_org_member_delete BEFORE DELETE ON organization_members FOR EACH ROW EXECUTE FUNCTION delete_skill_user_acl_on_org_member_delete();
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete BEFORE DELETE ON group_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete();
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete BEFORE DELETE ON organization_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete();
@@ -5231,6 +5281,8 @@ CREATE TRIGGER trigger_set_chat_message_revision_on_insert BEFORE INSERT ON chat
CREATE TRIGGER trigger_set_chat_message_revision_on_update BEFORE UPDATE ON chat_messages FOR EACH ROW EXECUTE FUNCTION set_chat_message_revision_before();
CREATE TRIGGER trigger_skills_per_owner_limit BEFORE INSERT ON skills FOR EACH ROW EXECUTE FUNCTION enforce_skills_per_owner_limit();
CREATE TRIGGER trigger_sync_chat_retry_state BEFORE UPDATE OF retry_state, retry_state_version, generation_attempt ON chats FOR EACH ROW EXECUTE FUNCTION sync_chat_retry_state();
CREATE TRIGGER trigger_update_chat_history_after_message_insert AFTER INSERT ON chat_messages REFERENCING NEW TABLE AS chat_message_history_new_rows FOR EACH STATEMENT EXECUTE FUNCTION update_chat_history_after_message_insert();
@@ -5239,16 +5291,14 @@ CREATE TRIGGER trigger_update_chat_history_after_message_update AFTER UPDATE ON
CREATE TRIGGER trigger_update_users AFTER INSERT OR UPDATE ON users FOR EACH ROW WHEN ((new.deleted = true)) EXECUTE FUNCTION delete_deleted_user_resources();
CREATE TRIGGER trigger_upsert_skills BEFORE INSERT OR UPDATE ON skills FOR EACH ROW WHEN ((new.user_id IS NOT NULL)) EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links FOR EACH ROW EXECUTE FUNCTION insert_user_links_fail_if_user_deleted();
CREATE TRIGGER trigger_upsert_user_secrets BEFORE INSERT OR UPDATE ON user_secrets FOR EACH ROW EXECUTE FUNCTION insert_user_secret_fail_if_user_deleted();
CREATE TRIGGER trigger_upsert_user_skills BEFORE INSERT OR UPDATE ON user_skills FOR EACH ROW EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
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_user_skills_per_user_limit BEFORE INSERT ON user_skills FOR EACH ROW EXECUTE FUNCTION enforce_user_skills_per_user_limit();
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();
@@ -5553,6 +5603,15 @@ ALTER TABLE ONLY provisioner_jobs
ALTER TABLE ONLY provisioner_keys
ADD CONSTRAINT provisioner_keys_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY skills
ADD CONSTRAINT skills_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY skills
ADD CONSTRAINT skills_project_id_fkey FOREIGN KEY (project_id) REFERENCES chat_projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY skills
ADD CONSTRAINT skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY tailnet_peers
ADD CONSTRAINT tailnet_peers_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE;
@@ -5637,9 +5696,6 @@ ALTER TABLE ONLY user_secrets
ALTER TABLE ONLY user_secrets
ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY user_skills
ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_status_changes
ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
+3 -1
View File
@@ -104,6 +104,9 @@ const (
ForeignKeyProvisionerJobTimingsJobID ForeignKeyConstraint = "provisioner_job_timings_job_id_fkey" // ALTER TABLE ONLY provisioner_job_timings ADD CONSTRAINT provisioner_job_timings_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
ForeignKeyProvisionerJobsOrganizationID ForeignKeyConstraint = "provisioner_jobs_organization_id_fkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyProvisionerKeysOrganizationID ForeignKeyConstraint = "provisioner_keys_organization_id_fkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeySkillsOrganizationID ForeignKeyConstraint = "skills_organization_id_fkey" // ALTER TABLE ONLY skills ADD CONSTRAINT skills_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeySkillsProjectID ForeignKeyConstraint = "skills_project_id_fkey" // ALTER TABLE ONLY skills ADD CONSTRAINT skills_project_id_fkey FOREIGN KEY (project_id) REFERENCES chat_projects(id) ON DELETE CASCADE;
ForeignKeySkillsUserID ForeignKeyConstraint = "skills_user_id_fkey" // ALTER TABLE ONLY skills ADD CONSTRAINT skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyTailnetPeersCoordinatorID ForeignKeyConstraint = "tailnet_peers_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE;
ForeignKeyTailnetTunnelsCoordinatorID ForeignKeyConstraint = "tailnet_tunnels_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE;
ForeignKeyTemplateUsageStatsSessionStartTimeTemplateIDUser ForeignKeyConstraint = "template_usage_stats_session__start_time_template_id_user__fkey" // ALTER TABLE ONLY template_usage_stats_session_apps ADD CONSTRAINT template_usage_stats_session__start_time_template_id_user__fkey FOREIGN KEY (start_time, template_id, user_id) REFERENCES template_usage_stats(start_time, template_id, user_id) ON DELETE CASCADE;
@@ -132,7 +135,6 @@ const (
ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserSecretsValueKeyID ForeignKeyConstraint = "user_secrets_value_key_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyUserSkillsUserID ForeignKeyConstraint = "user_skills_user_id_fkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentContextResourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_context_resources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_context_resources ADD CONSTRAINT workspace_agent_context_resources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
@@ -0,0 +1,118 @@
-- resource_type and api_key_scope enum values cannot be dropped.
-- Only personal skills fit the user_skills shape.
DELETE FROM skills WHERE user_id IS NULL;
DROP TRIGGER trigger_delete_skill_user_acl_on_org_member_delete ON organization_members;
DROP FUNCTION delete_skill_user_acl_on_org_member_delete();
DROP TRIGGER trigger_upsert_skills ON skills;
DROP TRIGGER trigger_skills_per_owner_limit ON skills;
DROP FUNCTION enforce_skills_per_owner_limit();
DROP INDEX skills_project_id_name_idx;
DROP INDEX skills_organization_id_name_idx;
ALTER TABLE skills
DROP CONSTRAINT skills_acl_only_on_organization_skills,
DROP CONSTRAINT skills_user_acl_is_object,
DROP CONSTRAINT skills_group_acl_is_object,
DROP CONSTRAINT skills_single_owner,
DROP COLUMN user_acl,
DROP COLUMN group_acl,
DROP COLUMN enabled,
DROP COLUMN project_id,
DROP COLUMN organization_id,
ALTER COLUMN user_id SET NOT NULL;
ALTER INDEX skills_user_id_name_idx RENAME TO user_skills_user_id_name_idx;
ALTER TABLE skills RENAME CONSTRAINT skills_content_size TO user_skills_content_size;
ALTER TABLE skills RENAME CONSTRAINT skills_description_size TO user_skills_description_size;
ALTER TABLE skills RENAME CONSTRAINT skills_name_format TO user_skills_name_format;
ALTER TABLE skills RENAME CONSTRAINT skills_name_size TO user_skills_name_size;
ALTER TABLE skills RENAME CONSTRAINT skills_user_id_fkey TO user_skills_user_id_fkey;
ALTER TABLE skills RENAME CONSTRAINT skills_pkey TO user_skills_pkey;
ALTER TABLE skills RENAME TO user_skills;
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
skill_count int;
skill_limit constant int := 100;
BEGIN
-- Serialize skill-cap checks per user so concurrent inserts cannot all
-- observe the same pre-insert count and exceed the hard limit.
PERFORM 1
FROM users
WHERE id = NEW.user_id
FOR UPDATE;
SELECT count(*) INTO skill_count
FROM user_skills
WHERE user_id = NEW.user_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'user has reached the personal skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_skills_per_user_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_user_skills_per_user_limit
BEFORE INSERT ON user_skills
FOR EACH ROW
EXECUTE FUNCTION enforce_user_skills_per_user_limit();
CREATE TRIGGER trigger_upsert_user_skills
BEFORE INSERT OR UPDATE ON user_skills
FOR EACH ROW
EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
IF (NEW.deleted) THEN
-- Remove their api_keys.
DELETE FROM api_keys
WHERE user_id = OLD.id;
-- Remove their user_links.
-- Their login_type is preserved in the users table.
-- Matching this user back to the link can still be done by their
-- email if the account is undeleted. Although that is not a guarantee.
DELETE FROM user_links
WHERE user_id = OLD.id;
-- Remove their user_secrets.
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_secrets
WHERE user_id = OLD.id;
-- Remove their user AI provider keys.
-- user_ai_provider_keys.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_ai_provider_keys
WHERE user_id = OLD.id;
-- Remove their organization memberships.
-- This also triggers group membership cleanup via
-- trigger_delete_group_members_on_org_member_delete.
DELETE FROM organization_members
WHERE user_id = OLD.id;
-- Remove their user_skills.
-- user_skills.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_skills
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
END;
$$;
@@ -0,0 +1,160 @@
-- Generalize personal skills into one skills table owned by a user, an
-- organization, or a project.
ALTER TABLE user_skills RENAME TO skills;
ALTER TABLE skills RENAME CONSTRAINT user_skills_pkey TO skills_pkey;
ALTER TABLE skills RENAME CONSTRAINT user_skills_user_id_fkey TO skills_user_id_fkey;
ALTER TABLE skills RENAME CONSTRAINT user_skills_name_size TO skills_name_size;
ALTER TABLE skills RENAME CONSTRAINT user_skills_name_format TO skills_name_format;
ALTER TABLE skills RENAME CONSTRAINT user_skills_description_size TO skills_description_size;
ALTER TABLE skills RENAME CONSTRAINT user_skills_content_size TO skills_content_size;
ALTER INDEX user_skills_user_id_name_idx RENAME TO skills_user_id_name_idx;
-- The per-user cap and the deleted-user guard are replaced below with
-- owner-aware versions.
DROP TRIGGER trigger_user_skills_per_user_limit ON skills;
DROP FUNCTION enforce_user_skills_per_user_limit();
DROP TRIGGER trigger_upsert_user_skills ON skills;
ALTER TABLE skills
ALTER COLUMN user_id DROP NOT NULL,
ADD COLUMN organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
ADD COLUMN project_id uuid REFERENCES chat_projects(id) ON DELETE CASCADE,
ADD COLUMN enabled boolean NOT NULL DEFAULT true,
ADD COLUMN group_acl jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN user_acl jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD CONSTRAINT skills_single_owner CHECK (num_nonnulls(user_id, organization_id, project_id) = 1),
ADD CONSTRAINT skills_group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object'),
ADD CONSTRAINT skills_user_acl_is_object CHECK (jsonb_typeof(user_acl) = 'object'),
ADD CONSTRAINT skills_acl_only_on_organization_skills CHECK (
organization_id IS NOT NULL OR (group_acl = '{}'::jsonb AND user_acl = '{}'::jsonb)
);
CREATE UNIQUE INDEX skills_organization_id_name_idx ON skills (organization_id, name) WHERE organization_id IS NOT NULL;
CREATE UNIQUE INDEX skills_project_id_name_idx ON skills (project_id, name) WHERE project_id IS NOT NULL;
CREATE FUNCTION enforce_skills_per_owner_limit() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
skill_count int;
skill_limit constant int := 100;
BEGIN
-- Serialize skill-cap checks per owner so concurrent inserts cannot all
-- observe the same pre-insert count and exceed the hard limit. FOR NO KEY
-- UPDATE does not block the KEY SHARE locks of unrelated foreign keys.
IF NEW.user_id IS NOT NULL THEN
PERFORM 1 FROM users WHERE id = NEW.user_id FOR NO KEY UPDATE;
SELECT count(*) INTO skill_count FROM skills WHERE user_id = NEW.user_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'user has reached the personal skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'skills_per_user_limit';
END IF;
ELSIF NEW.organization_id IS NOT NULL THEN
PERFORM 1 FROM organizations WHERE id = NEW.organization_id FOR NO KEY UPDATE;
SELECT count(*) INTO skill_count FROM skills WHERE organization_id = NEW.organization_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'organization has reached the skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'skills_per_organization_limit';
END IF;
ELSIF NEW.project_id IS NOT NULL THEN
PERFORM 1 FROM chat_projects WHERE id = NEW.project_id FOR NO KEY UPDATE;
SELECT count(*) INTO skill_count FROM skills WHERE project_id = NEW.project_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'project has reached the skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'skills_per_project_limit';
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_skills_per_owner_limit
BEFORE INSERT ON skills
FOR EACH ROW
EXECUTE FUNCTION enforce_skills_per_owner_limit();
CREATE TRIGGER trigger_upsert_skills
BEFORE INSERT OR UPDATE ON skills
FOR EACH ROW
WHEN (NEW.user_id IS NOT NULL)
EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
-- PL/pgSQL bodies are stored as text, so table renames do not reach them.
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
IF (NEW.deleted) THEN
-- Remove their api_keys.
DELETE FROM api_keys
WHERE user_id = OLD.id;
-- Remove their user_links.
-- Their login_type is preserved in the users table.
-- Matching this user back to the link can still be done by their
-- email if the account is undeleted. Although that is not a guarantee.
DELETE FROM user_links
WHERE user_id = OLD.id;
-- Remove their user_secrets.
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_secrets
WHERE user_id = OLD.id;
-- Remove their user AI provider keys.
-- user_ai_provider_keys.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_ai_provider_keys
WHERE user_id = OLD.id;
-- Remove their organization memberships.
-- This also triggers group membership cleanup via
-- trigger_delete_group_members_on_org_member_delete.
DELETE FROM organization_members
WHERE user_id = OLD.id;
-- Remove their personal skills.
-- skills.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM skills
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION delete_skill_user_acl_on_org_member_delete() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Drop the departing member's direct grants so adding them back to the
-- organization does not silently restore access.
UPDATE skills
SET
user_acl = user_acl - OLD.user_id::text,
updated_at = now()
WHERE organization_id = OLD.organization_id AND user_acl ? OLD.user_id::text;
RETURN OLD;
END;
$$;
CREATE TRIGGER trigger_delete_skill_user_acl_on_org_member_delete
BEFORE DELETE ON organization_members
FOR EACH ROW
EXECUTE FUNCTION delete_skill_user_acl_on_org_member_delete();
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'organization_skill';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_skill:*';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_skill:create';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_skill:read';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_skill:update';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_skill:delete';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_skill:share';
+183
View File
@@ -405,6 +405,7 @@ func TestMigrationChain(t *testing.T) {
{"Migration000595RemoveTaskPermissions", 595, testMigration000595RemoveTaskPermissions},
{"Migration000602RestoreAgentsAccessDefaultRole", 602, testMigration000602RestoreAgentsAccessDefaultRole},
{"Migration000606ChatDiffStatusOriginCredentials", 606, testMigration000606ChatDiffStatusOriginCredentials},
{"Migration000621Skills", 621, testMigration000621Skills},
}
for _, step := range steps {
stepTo(step.version - 1)
@@ -1841,6 +1842,188 @@ func testMigration000606ChatDiffStatusOriginCredentials(t *testing.T, sqlDB *sql
}
}
func testMigration000621Skills(t *testing.T, sqlDB *sql.DB, next migrationStepper) {
const migrationVersion = 621
ctx := testutil.Context(t, testutil.WaitLong)
insertUser := func() uuid.UUID {
t.Helper()
id := uuid.New()
_, err := sqlDB.ExecContext(ctx,
"INSERT INTO users (id, username, email, hashed_password, created_at, updated_at) VALUES ($1, $2, $3, '', NOW(), NOW())",
id, id.String(), id.String()+"@test.com")
require.NoError(t, err)
return id
}
type skillTable struct{ name, insert, exists string }
userSkillsTable := skillTable{
name: "user_skills",
insert: "INSERT INTO user_skills (id, user_id, name, description, content) VALUES ($1, $2, $3, 'desc', 'body')",
exists: "SELECT EXISTS (SELECT 1 FROM user_skills WHERE id = $1)",
}
skillsTable := skillTable{
name: "skills",
insert: "INSERT INTO skills (id, user_id, name, description, content) VALUES ($1, $2, $3, 'desc', 'body')",
exists: "SELECT EXISTS (SELECT 1 FROM skills WHERE id = $1)",
}
insertSkill := func(table skillTable, userID uuid.UUID) uuid.UUID {
t.Helper()
id := uuid.New()
_, err := sqlDB.ExecContext(ctx, table.insert, id, userID, "skill-"+id.String())
require.NoError(t, err)
return id
}
skillExists := func(table skillTable, id uuid.UUID) bool {
t.Helper()
var exists bool
err := sqlDB.QueryRowContext(ctx, table.exists, id).Scan(&exists)
require.NoError(t, err)
return exists
}
// Soft deletion runs delete_deleted_user_resources, whose body must name
// the current table.
softDeleteRemovesSkills := func(table skillTable) {
t.Helper()
userID := insertUser()
skillID := insertSkill(table, userID)
_, err := sqlDB.ExecContext(ctx, "UPDATE users SET deleted = true WHERE id = $1", userID)
require.NoError(t, err)
require.False(t, skillExists(table, skillID))
}
capFunction := func(table skillTable) string {
t.Helper()
var def string
err := sqlDB.QueryRowContext(ctx, `
SELECT pg_get_functiondef(tgfoid) FROM pg_trigger
WHERE tgrelid = $1::regclass AND tgname LIKE 'trigger_%skills_per_%'`, table.name).Scan(&def)
require.NoError(t, err)
return def
}
userNullable := func(table skillTable) string {
t.Helper()
var nullable string
err := sqlDB.QueryRowContext(ctx,
"SELECT is_nullable FROM information_schema.columns WHERE table_name = $1 AND column_name = 'user_id'",
table.name).Scan(&nullable)
require.NoError(t, err)
return nullable
}
oldNames := []string{
"trigger_upsert_user_skills:insert_user_skill_fail_if_user_deleted",
"trigger_user_skills_per_user_limit:enforce_user_skills_per_user_limit",
"user_skills_content_size",
"user_skills_description_size",
"user_skills_name_format",
"user_skills_name_size",
"user_skills_pkey",
"user_skills_user_id_fkey",
"user_skills_user_id_name_idx",
}
require.Equal(t, oldNames, skillsSchemaNames(ctx, t, sqlDB, userSkillsTable.name))
oldCapFunction := capFunction(userSkillsTable)
require.Equal(t, "NO", userNullable(userSkillsTable))
userID := insertUser()
personalSkillID := insertSkill(userSkillsTable, userID)
version, _, err := next()
require.NoError(t, err)
require.EqualValues(t, migrationVersion, version)
require.Equal(t, []string{
"skills_acl_only_on_organization_skills",
"skills_content_size",
"skills_description_size",
"skills_group_acl_is_object",
"skills_name_format",
"skills_name_size",
"skills_organization_id_fkey",
"skills_organization_id_name_idx",
"skills_pkey",
"skills_project_id_fkey",
"skills_project_id_name_idx",
"skills_single_owner",
"skills_user_acl_is_object",
"skills_user_id_fkey",
"skills_user_id_name_idx",
"trigger_skills_per_owner_limit:enforce_skills_per_owner_limit",
"trigger_upsert_skills:insert_user_skill_fail_if_user_deleted",
}, skillsSchemaNames(ctx, t, sqlDB, skillsTable.name))
require.Equal(t, "YES", userNullable(skillsTable))
var (
gotUserID, orgID, projectID uuid.NullUUID
name, description, content string
enabled bool
groupACL, userACL string
)
err = sqlDB.QueryRowContext(ctx,
"SELECT user_id, organization_id, project_id, name, description, content, enabled, group_acl::text, user_acl::text FROM skills WHERE id = $1",
personalSkillID).Scan(&gotUserID, &orgID, &projectID, &name, &description, &content, &enabled, &groupACL, &userACL)
require.NoError(t, err)
require.Equal(t, uuid.NullUUID{UUID: userID, Valid: true}, gotUserID)
require.False(t, orgID.Valid)
require.False(t, projectID.Valid)
require.Equal(t, "skill-"+personalSkillID.String(), name)
require.Equal(t, "desc", description)
require.Equal(t, "body", content)
require.True(t, enabled)
require.Equal(t, "{}", groupACL)
require.Equal(t, "{}", userACL)
softDeleteRemovesSkills(skillsTable)
org := uuid.New()
_, err = sqlDB.ExecContext(ctx,
"INSERT INTO organizations (id, name, display_name, description, created_at, updated_at, default_org_member_roles) VALUES ($1, $2, $2, '', NOW(), NOW(), '{}')",
org, "org-"+org.String())
require.NoError(t, err)
orgSkillID := uuid.New()
_, err = sqlDB.ExecContext(ctx,
"INSERT INTO skills (id, organization_id, name, description, content) VALUES ($1, $2, 'org', 'desc', 'body')",
orgSkillID, org)
require.NoError(t, err)
downSQL, err := os.ReadFile("000621_skills.down.sql")
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, string(downSQL))
require.NoError(t, err)
require.Equal(t, oldNames, skillsSchemaNames(ctx, t, sqlDB, userSkillsTable.name))
require.Equal(t, oldCapFunction, capFunction(userSkillsTable))
require.Equal(t, "NO", userNullable(userSkillsTable))
require.True(t, skillExists(userSkillsTable, personalSkillID))
require.False(t, skillExists(userSkillsTable, orgSkillID))
softDeleteRemovesSkills(userSkillsTable)
upSQL, err := os.ReadFile("000621_skills.up.sql")
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, string(upSQL))
require.NoError(t, err)
require.True(t, skillExists(skillsTable, personalSkillID))
softDeleteRemovesSkills(skillsTable)
}
// skillsSchemaNames lists a table's constraints, indexes, and triggers with
// their functions.
func skillsSchemaNames(ctx context.Context, t *testing.T, sqlDB *sql.DB, table string) []string {
t.Helper()
rows, err := sqlDB.QueryContext(ctx, `
SELECT conname::text FROM pg_constraint WHERE conrelid = $1::regclass
UNION SELECT indexname::text FROM pg_indexes WHERE tablename = $1::text
UNION SELECT tgname::text || ':' || tgfoid::regproc::text FROM pg_trigger WHERE tgrelid = $1::regclass AND NOT tgisinternal
ORDER BY 1`, table)
require.NoError(t, err)
defer rows.Close()
var names []string
for rows.Next() {
var name string
require.NoError(t, rows.Scan(&name))
names = append(names, name)
}
require.NoError(t, rows.Err())
return names
}
func TestMigration000504AIProvidersBackfill(t *testing.T) {
t.Parallel()
@@ -0,0 +1,21 @@
INSERT INTO skills (
id,
organization_id,
name,
description,
content,
enabled,
group_acl,
created_at,
updated_at
) VALUES (
'7f070eb2-991e-4f7f-b780-40c4e0f49002',
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1',
'example-skill',
'Example organization skill fixture.',
'Example content.',
false,
'{"bb640d07-ca8a-4869-b6bc-ae61ebb2fda1": {"permissions": ["read"]}}',
'2026-10-09 00:00:00+00',
'2026-10-09 00:00:00+00'
);
+14 -2
View File
@@ -958,8 +958,20 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce(
return m.DebouncedUntil, false
}
func (s UserSkill) RBACObject() rbac.Object {
return rbac.ResourceUserSkill.WithID(s.ID).WithOwner(s.UserID.String())
func (s Skill) RBACObject() rbac.Object {
if s.UserID.Valid {
return rbac.ResourceUserSkill.WithID(s.ID).WithOwner(s.UserID.UUID.String())
}
if s.OrganizationID.Valid {
return rbac.ResourceOrganizationSkill.
WithID(s.ID).
InOrg(s.OrganizationID.UUID).
WithGroupACL(s.GroupACL.RBACACL()).
WithACLUserList(s.UserACL.RBACACL())
}
// No role grants the empty resource type, so rows without an RBAC
// resource stay unauthorizable for every user, including site owners.
return rbac.Object{ID: s.ID.String()}
}
func (s UserSecret) RBACObject() rbac.Object {
@@ -1,9 +1,11 @@
package database
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/rbac"
@@ -270,3 +272,48 @@ func requireAllowAll(t *testing.T, s rbac.Scope) {
require.Equal(t, policy.WildcardSymbol, s.AllowIDList[0].ID)
require.Equal(t, policy.WildcardSymbol, s.AllowIDList[0].Type)
}
func TestSkillRBACObjectProjectRowsDenied(t *testing.T) {
t.Parallel()
orgID := uuid.New()
skill := Skill{ID: uuid.New(), ProjectID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}
authz := rbac.NewAuthorizer(prometheus.NewRegistry())
for name, roles := range map[string]rbac.RoleIdentifiers{
"SiteOwner": {rbac.RoleOwner(), rbac.RoleMember()},
"OrgAdmin": {rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(orgID)},
"Member": {rbac.RoleMember(), rbac.ScopedRoleAgentsAccess(orgID)},
} {
subject := rbac.Subject{ID: uuid.NewString(), Roles: roles, Scope: rbac.ScopeAll}
for _, action := range []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete} {
err := authz.Authorize(context.Background(), subject, action, skill.RBACObject())
require.Error(t, err, "%s %s", name, action)
}
}
}
func TestSkillRBACObjectOrganizationACL(t *testing.T) {
t.Parallel()
orgID, groupID, userID := uuid.New(), uuid.New(), uuid.New()
read := ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}}
authz := rbac.NewAuthorizer(prometheus.NewRegistry())
for name, tc := range map[string]struct {
skill Skill
groups []string
}{
"UserGrant": {skill: Skill{UserACL: ChatACL{userID.String(): read}}},
"GroupGrant": {skill: Skill{GroupACL: ChatACL{groupID.String(): read}}, groups: []string{groupID.String()}},
} {
tc.skill.ID = uuid.New()
tc.skill.OrganizationID = uuid.NullUUID{UUID: orgID, Valid: true}
subject := rbac.Subject{
ID: userID.String(),
Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleAgentsAccess(orgID)},
Groups: tc.groups,
Scope: rbac.ScopeAll,
}
require.NoError(t, authz.Authorize(context.Background(), subject, policy.ActionRead, tc.skill.RBACObject()), name)
require.Error(t, authz.Authorize(context.Background(), subject, policy.ActionUpdate, tc.skill.RBACObject()), name)
}
}
+47
View File
@@ -55,6 +55,7 @@ type customQuerier interface {
chatQuerier
chatModelConfigQuerier
mcpServerConfigQuerier
organizationSkillQuerier
}
type chatModelConfigQuerier interface {
@@ -1348,3 +1349,49 @@ func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, organiza
}
return items, nil
}
type organizationSkillQuerier interface {
GetAuthorizedOrganizationSkillMetadata(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]ListOrganizationSkillMetadataByOrganizationIDRow, error)
}
func (q *sqlQuerier) GetAuthorizedOrganizationSkillMetadata(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]ListOrganizationSkillMetadataByOrganizationIDRow, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.OrganizationSkillConverter(),
})
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
}
filtered, err := insertAuthorizedFilter(listOrganizationSkillMetadataByOrganizationID, fmt.Sprintf(" AND %s", authorizedFilter))
if err != nil {
return nil, xerrors.Errorf("insert authorized filter: %w", err)
}
// The name comment is for metric tracking
query := fmt.Sprintf("-- name: GetAuthorizedOrganizationSkillMetadata :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListOrganizationSkillMetadataByOrganizationIDRow
for rows.Next() {
var i ListOrganizationSkillMetadataByOrganizationIDRow
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.Name,
&i.Description,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+38 -12
View File
@@ -539,6 +539,12 @@ const (
ApiKeyScopeChatProjectMemoryCreate APIKeyScope = "chat_project_memory:create"
ApiKeyScopeChatProjectMemoryRead APIKeyScope = "chat_project_memory:read"
ApiKeyScopeChatProjectMemoryDelete APIKeyScope = "chat_project_memory:delete"
ApiKeyScopeOrganizationSkill APIKeyScope = "organization_skill:*"
ApiKeyScopeOrganizationSkillCreate APIKeyScope = "organization_skill:create"
ApiKeyScopeOrganizationSkillRead APIKeyScope = "organization_skill:read"
ApiKeyScopeOrganizationSkillUpdate APIKeyScope = "organization_skill:update"
ApiKeyScopeOrganizationSkillDelete APIKeyScope = "organization_skill:delete"
ApiKeyScopeOrganizationSkillShare APIKeyScope = "organization_skill:share"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -829,7 +835,13 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeChatProjectMemory,
ApiKeyScopeChatProjectMemoryCreate,
ApiKeyScopeChatProjectMemoryRead,
ApiKeyScopeChatProjectMemoryDelete:
ApiKeyScopeChatProjectMemoryDelete,
ApiKeyScopeOrganizationSkill,
ApiKeyScopeOrganizationSkillCreate,
ApiKeyScopeOrganizationSkillRead,
ApiKeyScopeOrganizationSkillUpdate,
ApiKeyScopeOrganizationSkillDelete,
ApiKeyScopeOrganizationSkillShare:
return true
}
return false
@@ -1089,6 +1101,12 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeChatProjectMemoryCreate,
ApiKeyScopeChatProjectMemoryRead,
ApiKeyScopeChatProjectMemoryDelete,
ApiKeyScopeOrganizationSkill,
ApiKeyScopeOrganizationSkillCreate,
ApiKeyScopeOrganizationSkillRead,
ApiKeyScopeOrganizationSkillUpdate,
ApiKeyScopeOrganizationSkillDelete,
ApiKeyScopeOrganizationSkillShare,
}
}
@@ -3756,6 +3774,7 @@ const (
ResourceTypeChatProjectMemory ResourceType = "chat_project_memory"
ResourceTypeChatOrganizationSystemPrompt ResourceType = "chat_organization_system_prompt"
ResourceTypeChatProjectInstructions ResourceType = "chat_project_instructions"
ResourceTypeOrganizationSkill ResourceType = "organization_skill"
)
func (e *ResourceType) Scan(src interface{}) error {
@@ -3840,7 +3859,8 @@ func (e ResourceType) Valid() bool {
ResourceTypeChatAutomation,
ResourceTypeChatProjectMemory,
ResourceTypeChatOrganizationSystemPrompt,
ResourceTypeChatProjectInstructions:
ResourceTypeChatProjectInstructions,
ResourceTypeOrganizationSkill:
return true
}
return false
@@ -3894,6 +3914,7 @@ func AllResourceTypeValues() []ResourceType {
ResourceTypeChatProjectMemory,
ResourceTypeChatOrganizationSystemPrompt,
ResourceTypeChatProjectInstructions,
ResourceTypeOrganizationSkill,
}
}
@@ -6074,6 +6095,21 @@ type SiteConfig struct {
Value string `db:"value" json:"value"`
}
type Skill struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.NullUUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
ProjectID uuid.NullUUID `db:"project_id" json:"project_id"`
Enabled bool `db:"enabled" json:"enabled"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
}
// We keep this separate from replicas in case we need to break the coordinator out into its own service
type TailnetCoordinator struct {
ID uuid.UUID `db:"id" json:"id"`
@@ -6495,16 +6531,6 @@ type UserSecret struct {
Enabled bool `db:"enabled" json:"enabled"`
}
type UserSkill struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Tracks the history of user status changes
type UserStatusChange struct {
ID uuid.UUID `db:"id" json:"id"`
+9 -4
View File
@@ -238,6 +238,7 @@ type sqlcQuerier interface {
DeleteOldWorkspaceAgentStats(ctx context.Context) error
DeleteOldWorkspaceBuildOrchestrations(ctx context.Context, arg DeleteOldWorkspaceBuildOrchestrationsParams) (int64, error)
DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error
DeleteOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg DeleteOrganizationSkillByOrganizationIDAndNameParams) (Skill, error)
DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error
DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error
DeleteRuntimeConfig(ctx context.Context, key string) error
@@ -254,7 +255,7 @@ type sqlcQuerier interface {
DeleteUserAIProviderKeysByProviderID(ctx context.Context, aiProviderID uuid.UUID) error
DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error
DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) (UserSecret, error)
DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (UserSkill, error)
DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (Skill, error)
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error
@@ -789,6 +790,7 @@ type sqlcQuerier interface {
GetOrganizationGroupsAISpend(ctx context.Context, arg GetOrganizationGroupsAISpendParams) ([]GetOrganizationGroupsAISpendRow, error)
GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error)
GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (GetOrganizationResourceCountByIDRow, error)
GetOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg GetOrganizationSkillByOrganizationIDAndNameParams) (Skill, error)
GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error)
GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error)
// GetOrganizationsWithPrebuildStatus returns organizations with prebuilds configured and their
@@ -1057,7 +1059,7 @@ type sqlcQuerier interface {
// values rather than interpolating between rows.
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error)
GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error)
GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (UserSkill, error)
GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (Skill, error)
// GetUserStatusCounts returns the count of users in each status over time.
// The time range is inclusively defined by the start_time and end_time parameters.
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
@@ -1247,6 +1249,7 @@ type sqlcQuerier interface {
InsertOAuth2ProviderAppToken(ctx context.Context, arg InsertOAuth2ProviderAppTokenParams) (OAuth2ProviderAppToken, error)
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
InsertOrganizationSkill(ctx context.Context, arg InsertOrganizationSkillParams) (Skill, error)
InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error)
InsertPresetParameters(ctx context.Context, arg InsertPresetParametersParams) ([]TemplateVersionPresetParameter, error)
InsertPresetPrebuildSchedule(ctx context.Context, arg InsertPresetPrebuildScheduleParams) (TemplateVersionPresetPrebuildSchedule, error)
@@ -1276,7 +1279,7 @@ type sqlcQuerier interface {
// If there is a conflict, the user is already a member
InsertUserGroupsByID(ctx context.Context, arg InsertUserGroupsByIDParams) ([]uuid.UUID, error)
InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error)
InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (UserSkill, error)
InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (Skill, error)
InsertVolumeResourceMonitor(ctx context.Context, arg InsertVolumeResourceMonitorParams) (WorkspaceAgentVolumeResourceMonitor, error)
// Inserts or updates a webpush subscription. The (user_id, endpoint) pair
// is unique; re-subscribing the same endpoint replaces the keys instead of
@@ -1373,6 +1376,7 @@ type sqlcQuerier interface {
// totals over every matching user. It must keep the same joins and predicates as
// ExportOrganizationAISpend so both report the same token usage.
ListOrganizationAISpendUsers(ctx context.Context, arg ListOrganizationAISpendUsersParams) ([]ListOrganizationAISpendUsersRow, error)
ListOrganizationSkillMetadataByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationSkillMetadataByOrganizationIDRow, error)
ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error)
ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error)
// Used by the usage generator to find missing heartbeat buckets.
@@ -1644,6 +1648,7 @@ type sqlcQuerier interface {
UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error)
UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error)
UpdateOrganizationDeletedByID(ctx context.Context, arg UpdateOrganizationDeletedByIDParams) error
UpdateOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg UpdateOrganizationSkillByOrganizationIDAndNameParams) (Skill, error)
UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error)
// Cancels all pending provisioner jobs for prebuilt workspaces on a specific preset from an
// inactive template version.
@@ -1696,7 +1701,7 @@ type sqlcQuerier interface {
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, error)
UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error)
UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (UserSkill, error)
UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (Skill, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error)
UpdateUserThemeDark(ctx context.Context, arg UpdateUserThemeDarkParams) (UserConfig, error)
+393 -171
View File
@@ -28447,6 +28447,399 @@ func (q *sqlQuerier) UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpu
return err
}
const deleteOrganizationSkillByOrganizationIDAndName = `-- name: DeleteOrganizationSkillByOrganizationIDAndName :one
DELETE FROM skills
WHERE organization_id = $1::uuid AND name = $2
RETURNING id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
`
type DeleteOrganizationSkillByOrganizationIDAndNameParams struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) DeleteOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg DeleteOrganizationSkillByOrganizationIDAndNameParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, deleteOrganizationSkillByOrganizationIDAndName, arg.OrganizationID, arg.Name)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const deleteUserSkillByUserIDAndName = `-- name: DeleteUserSkillByUserIDAndName :one
DELETE FROM skills
WHERE user_id = $1::uuid AND name = $2
RETURNING id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
`
type DeleteUserSkillByUserIDAndNameParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, deleteUserSkillByUserIDAndName, arg.UserID, arg.Name)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const getOrganizationSkillByOrganizationIDAndName = `-- name: GetOrganizationSkillByOrganizationIDAndName :one
SELECT id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
FROM skills
WHERE organization_id = $1::uuid AND name = $2
`
type GetOrganizationSkillByOrganizationIDAndNameParams struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg GetOrganizationSkillByOrganizationIDAndNameParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, getOrganizationSkillByOrganizationIDAndName, arg.OrganizationID, arg.Name)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const getUserSkillByUserIDAndName = `-- name: GetUserSkillByUserIDAndName :one
SELECT id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
FROM skills
WHERE user_id = $1::uuid AND name = $2
`
type GetUserSkillByUserIDAndNameParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, getUserSkillByUserIDAndName, arg.UserID, arg.Name)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const insertOrganizationSkill = `-- name: InsertOrganizationSkill :one
INSERT INTO skills (id, organization_id, name, description, content, group_acl, user_acl)
VALUES ($1::uuid, $2::uuid, $3::text, $4::text, $5::text, $6, $7)
RETURNING id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
`
type InsertOrganizationSkillParams struct {
ID uuid.UUID `db:"id" json:"id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
}
func (q *sqlQuerier) InsertOrganizationSkill(ctx context.Context, arg InsertOrganizationSkillParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, insertOrganizationSkill,
arg.ID,
arg.OrganizationID,
arg.Name,
arg.Description,
arg.Content,
arg.GroupACL,
arg.UserACL,
)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const insertUserSkill = `-- name: InsertUserSkill :one
INSERT INTO skills (id, user_id, name, description, content)
VALUES ($1::uuid, $2::uuid, $3::text, $4::text, $5::text)
RETURNING id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
`
type InsertUserSkillParams struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
}
func (q *sqlQuerier) InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, insertUserSkill,
arg.ID,
arg.UserID,
arg.Name,
arg.Description,
arg.Content,
)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const listOrganizationSkillMetadataByOrganizationID = `-- name: ListOrganizationSkillMetadataByOrganizationID :many
SELECT
id, organization_id, name, description, enabled, created_at, updated_at
FROM skills
WHERE organization_id = $1::uuid
-- Authorize Filter clause will be injected below in GetAuthorizedOrganizationSkillMetadata
-- @authorize_filter
ORDER BY name ASC
`
type ListOrganizationSkillMetadataByOrganizationIDRow struct {
ID uuid.UUID `db:"id" json:"id"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Enabled bool `db:"enabled" json:"enabled"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) ListOrganizationSkillMetadataByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationSkillMetadataByOrganizationIDRow, error) {
rows, err := q.db.QueryContext(ctx, listOrganizationSkillMetadataByOrganizationID, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListOrganizationSkillMetadataByOrganizationIDRow
for rows.Next() {
var i ListOrganizationSkillMetadataByOrganizationIDRow
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.Name,
&i.Description,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); 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 listUserSkillMetadataByUserID = `-- name: ListUserSkillMetadataByUserID :many
SELECT
id, user_id, name, description, created_at, updated_at
FROM skills
WHERE user_id = $1::uuid
ORDER BY name ASC
`
type ListUserSkillMetadataByUserIDRow struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.NullUUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error) {
rows, err := q.db.QueryContext(ctx, listUserSkillMetadataByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUserSkillMetadataByUserIDRow
for rows.Next() {
var i ListUserSkillMetadataByUserIDRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
); 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 updateOrganizationSkillByOrganizationIDAndName = `-- name: UpdateOrganizationSkillByOrganizationIDAndName :one
UPDATE skills
SET
description = COALESCE($1::text, description),
content = COALESCE($2::text, content),
enabled = COALESCE($3::boolean, enabled),
updated_at = now()
WHERE organization_id = $4::uuid AND name = $5
RETURNING id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
`
type UpdateOrganizationSkillByOrganizationIDAndNameParams struct {
Description sql.NullString `db:"description" json:"description"`
Content sql.NullString `db:"content" json:"content"`
Enabled sql.NullBool `db:"enabled" json:"enabled"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) UpdateOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg UpdateOrganizationSkillByOrganizationIDAndNameParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, updateOrganizationSkillByOrganizationIDAndName,
arg.Description,
arg.Content,
arg.Enabled,
arg.OrganizationID,
arg.Name,
)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const updateUserSkillByUserIDAndName = `-- name: UpdateUserSkillByUserIDAndName :one
UPDATE skills
SET
description = $1,
content = $2,
updated_at = now()
WHERE user_id = $3::uuid AND name = $4
RETURNING id, user_id, name, description, content, created_at, updated_at, organization_id, project_id, enabled, group_acl, user_acl
`
type UpdateUserSkillByUserIDAndNameParams struct {
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (Skill, error) {
row := q.db.QueryRowContext(ctx, updateUserSkillByUserIDAndName,
arg.Description,
arg.Content,
arg.UserID,
arg.Name,
)
var i Skill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.ProjectID,
&i.Enabled,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const cleanTailnetCoordinators = `-- name: CleanTailnetCoordinators :exec
DELETE
FROM tailnet_coordinators
@@ -32266,177 +32659,6 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up
return i, err
}
const deleteUserSkillByUserIDAndName = `-- name: DeleteUserSkillByUserIDAndName :one
DELETE FROM user_skills
WHERE user_id = $1 AND name = $2
RETURNING id, user_id, name, description, content, created_at, updated_at
`
type DeleteUserSkillByUserIDAndNameParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, deleteUserSkillByUserIDAndName, arg.UserID, arg.Name)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserSkillByUserIDAndName = `-- name: GetUserSkillByUserIDAndName :one
SELECT id, user_id, name, description, content, created_at, updated_at
FROM user_skills
WHERE user_id = $1 AND name = $2
`
type GetUserSkillByUserIDAndNameParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, getUserSkillByUserIDAndName, arg.UserID, arg.Name)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertUserSkill = `-- name: InsertUserSkill :one
INSERT INTO user_skills (id, user_id, name, description, content)
VALUES ($1::uuid, $2::uuid, $3::text, $4::text, $5::text)
RETURNING id, user_id, name, description, content, created_at, updated_at
`
type InsertUserSkillParams struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
}
func (q *sqlQuerier) InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, insertUserSkill,
arg.ID,
arg.UserID,
arg.Name,
arg.Description,
arg.Content,
)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listUserSkillMetadataByUserID = `-- name: ListUserSkillMetadataByUserID :many
SELECT
id, user_id, name, description, created_at, updated_at
FROM user_skills
WHERE user_id = $1
ORDER BY name ASC
`
type ListUserSkillMetadataByUserIDRow struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error) {
rows, err := q.db.QueryContext(ctx, listUserSkillMetadataByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUserSkillMetadataByUserIDRow
for rows.Next() {
var i ListUserSkillMetadataByUserIDRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
); 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 updateUserSkillByUserIDAndName = `-- name: UpdateUserSkillByUserIDAndName :one
UPDATE user_skills
SET
description = $1,
content = $2,
updated_at = now()
WHERE user_id = $3 AND name = $4
RETURNING id, user_id, name, description, content, created_at, updated_at
`
type UpdateUserSkillByUserIDAndNameParams struct {
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, updateUserSkillByUserIDAndName,
arg.Description,
arg.Content,
arg.UserID,
arg.Name,
)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const allUserIDs = `-- name: AllUserIDs :many
SELECT DISTINCT id FROM USERS
WHERE CASE WHEN $1::bool THEN TRUE ELSE is_system = false END
+64
View File
@@ -0,0 +1,64 @@
-- name: InsertUserSkill :one
INSERT INTO skills (id, user_id, name, description, content)
VALUES (@id::uuid, @user_id::uuid, @name::text, @description::text, @content::text)
RETURNING *;
-- name: GetUserSkillByUserIDAndName :one
SELECT *
FROM skills
WHERE user_id = @user_id::uuid AND name = @name;
-- name: ListUserSkillMetadataByUserID :many
SELECT
id, user_id, name, description, created_at, updated_at
FROM skills
WHERE user_id = @user_id::uuid
ORDER BY name ASC;
-- name: UpdateUserSkillByUserIDAndName :one
UPDATE skills
SET
description = @description,
content = @content,
updated_at = now()
WHERE user_id = @user_id::uuid AND name = @name
RETURNING *;
-- name: DeleteUserSkillByUserIDAndName :one
DELETE FROM skills
WHERE user_id = @user_id::uuid AND name = @name
RETURNING *;
-- name: InsertOrganizationSkill :one
INSERT INTO skills (id, organization_id, name, description, content, group_acl, user_acl)
VALUES (@id::uuid, @organization_id::uuid, @name::text, @description::text, @content::text, @group_acl, @user_acl)
RETURNING *;
-- name: GetOrganizationSkillByOrganizationIDAndName :one
SELECT *
FROM skills
WHERE organization_id = @organization_id::uuid AND name = @name;
-- name: ListOrganizationSkillMetadataByOrganizationID :many
SELECT
id, organization_id, name, description, enabled, created_at, updated_at
FROM skills
WHERE organization_id = @organization_id::uuid
-- Authorize Filter clause will be injected below in GetAuthorizedOrganizationSkillMetadata
-- @authorize_filter
ORDER BY name ASC;
-- name: UpdateOrganizationSkillByOrganizationIDAndName :one
UPDATE skills
SET
description = COALESCE(sqlc.narg('description')::text, description),
content = COALESCE(sqlc.narg('content')::text, content),
enabled = COALESCE(sqlc.narg('enabled')::boolean, enabled),
updated_at = now()
WHERE organization_id = @organization_id::uuid AND name = @name
RETURNING *;
-- name: DeleteOrganizationSkillByOrganizationIDAndName :one
DELETE FROM skills
WHERE organization_id = @organization_id::uuid AND name = @name
RETURNING *;
-30
View File
@@ -1,30 +0,0 @@
-- name: InsertUserSkill :one
INSERT INTO user_skills (id, user_id, name, description, content)
VALUES (@id::uuid, @user_id::uuid, @name::text, @description::text, @content::text)
RETURNING *;
-- name: GetUserSkillByUserIDAndName :one
SELECT *
FROM user_skills
WHERE user_id = @user_id AND name = @name;
-- name: ListUserSkillMetadataByUserID :many
SELECT
id, user_id, name, description, created_at, updated_at
FROM user_skills
WHERE user_id = @user_id
ORDER BY name ASC;
-- name: UpdateUserSkillByUserIDAndName :one
UPDATE user_skills
SET
description = @description,
content = @content,
updated_at = now()
WHERE user_id = @user_id AND name = @name
RETURNING *;
-- name: DeleteUserSkillByUserIDAndName :one
DELETE FROM user_skills
WHERE user_id = @user_id AND name = @name
RETURNING *;
+6
View File
@@ -95,6 +95,12 @@ sql:
- column: "mcp_server_configs.group_acl"
go_type:
type: "ChatACL"
- column: "skills.user_acl"
go_type:
type: "ChatACL"
- column: "skills.group_acl"
go_type:
type: "ChatACL"
- column: "chats_expanded.user_acl"
go_type:
type: "ChatACL"
+4 -2
View File
@@ -92,6 +92,7 @@ const (
UniqueProvisionerJobsPkey UniqueConstraint = "provisioner_jobs_pkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_pkey PRIMARY KEY (id);
UniqueProvisionerKeysPkey UniqueConstraint = "provisioner_keys_pkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_pkey PRIMARY KEY (id);
UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key);
UniqueSkillsPkey UniqueConstraint = "skills_pkey" // ALTER TABLE ONLY skills ADD CONSTRAINT skills_pkey PRIMARY KEY (id);
UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id);
UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id);
UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id);
@@ -119,7 +120,6 @@ const (
UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id);
UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type);
UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id);
UniqueUserSkillsPkey UniqueConstraint = "user_skills_pkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id);
UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id);
UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id);
@@ -177,13 +177,15 @@ const (
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
UniqueProvisionerKeysOrganizationIDNameIndex UniqueConstraint = "provisioner_keys_organization_id_name_idx" // CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text));
UniqueSkillsOrganizationIDNameIndex UniqueConstraint = "skills_organization_id_name_idx" // CREATE UNIQUE INDEX skills_organization_id_name_idx ON skills USING btree (organization_id, name) WHERE (organization_id IS NOT NULL);
UniqueSkillsProjectIDNameIndex UniqueConstraint = "skills_project_id_name_idx" // CREATE UNIQUE INDEX skills_project_id_name_idx ON skills USING btree (project_id, name) WHERE (project_id IS NOT NULL);
UniqueSkillsUserIDNameIndex UniqueConstraint = "skills_user_id_name_idx" // CREATE UNIQUE INDEX skills_user_id_name_idx ON skills USING btree (user_id, name);
UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id);
UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false);
UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text);
UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text);
UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text);
UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name);
UniqueUserSkillsUserIDNameIndex UniqueConstraint = "user_skills_user_id_name_idx" // CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name);
UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text));
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
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);
+325 -5
View File
@@ -1,13 +1,24 @@
package database_test
import (
"context"
"database/sql"
"fmt"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/x/skills"
"github.com/coder/coder/v2/testutil"
)
@@ -22,7 +33,7 @@ func TestUserSkillSchemaConstants(t *testing.T) {
_, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
var triggerDef string
err := sqlDB.QueryRowContext(ctx,
`SELECT pg_get_functiondef('enforce_user_skills_per_user_limit'::regproc)`,
`SELECT pg_get_functiondef('enforce_skills_per_owner_limit'::regproc)`,
).Scan(&triggerDef)
require.NoError(t, err)
require.Contains(t, triggerDef, fmt.Sprintf(
@@ -31,16 +42,16 @@ func TestUserSkillSchemaConstants(t *testing.T) {
))
constraints := map[database.CheckConstraint]string{
database.CheckUserSkillsNameSize: fmt.Sprintf(
database.CheckSkillsNameSize: fmt.Sprintf(
"octet_length(name) <= %d",
skills.MaxPersonalSkillNameBytes,
),
database.CheckUserSkillsNameFormat: "name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text",
database.CheckUserSkillsDescriptionSize: fmt.Sprintf(
database.CheckSkillsNameFormat: "name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text",
database.CheckSkillsDescriptionSize: fmt.Sprintf(
"octet_length(description) <= %d",
skills.MaxPersonalSkillDescriptionBytes,
),
database.CheckUserSkillsContentSize: fmt.Sprintf(
database.CheckSkillsContentSize: fmt.Sprintf(
"octet_length(content) <= %d",
skills.MaxPersonalSkillSizeBytes,
),
@@ -60,3 +71,312 @@ func TestUserSkillSchemaConstants(t *testing.T) {
})
}
}
func TestSkillOwners(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
type owners struct {
user, org, project uuid.NullUUID
}
type skillRow struct {
owners
name string
enabled bool
groupACL, userACL string
}
insertSkill := func(ctx context.Context, q interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}, row skillRow,
) error {
if row.name == "" {
row.name = "skill-" + uuid.NewString()
}
if row.groupACL == "" {
row.groupACL = "{}"
}
if row.userACL == "" {
row.userACL = "{}"
}
_, err := q.ExecContext(ctx, `
INSERT INTO skills (id, user_id, organization_id, project_id, name, description, content, enabled, group_acl, user_acl)
VALUES ($1, $2, $3, $4, $5, 'desc', 'body', $6, $7::jsonb, $8::jsonb)`,
uuid.New(), row.user, row.org, row.project, row.name, row.enabled, row.groupACL, row.userACL)
return err
}
countSkills := func(ctx context.Context, column string, id uuid.UUID) int {
var count int
//nolint:gosec // column is a fixed owner column name from this test.
err := sqlDB.QueryRowContext(ctx, "SELECT count(*) FROM skills WHERE "+column+" = $1", id).Scan(&count)
require.NoError(t, err)
return count
}
valid := func(id uuid.UUID) uuid.NullUUID { return uuid.NullUUID{UUID: id, Valid: true} }
newUser := func() uuid.UUID { return dbgen.User(t, db, database.User{}).ID }
newOrg := func() uuid.UUID { return dbgen.Organization(t, db, database.Organization{}).ID }
newProject := func() uuid.UUID {
org := newOrg()
return dbgen.ChatProject(t, db, database.ChatProject{OrganizationID: org, OwnerID: newUser()}).ID
}
t.Run("ExactlyOneOwner", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
user, org, project := valid(newUser()), valid(newOrg()), valid(newProject())
for _, o := range []owners{
{},
{user: user, org: org},
{org: org, project: project},
{user: user, project: project},
} {
err := insertSkill(ctx, sqlDB, skillRow{owners: o, enabled: true})
require.True(t, database.IsCheckViolation(err, database.CheckSkillsSingleOwner), "owners %+v: %v", o, err)
}
})
t.Run("ACLOnlyOnOrganizationSkills", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
grant := `{"` + uuid.NewString() + `": {"permissions": ["read"]}}`
for _, o := range []owners{{user: valid(newUser())}, {project: valid(newProject())}} {
err := insertSkill(ctx, sqlDB, skillRow{owners: o, groupACL: grant})
require.True(t, database.IsCheckViolation(err, database.CheckSkillsAclOnlyOnOrganizationSkills), "group ACL %+v: %v", o, err)
err = insertSkill(ctx, sqlDB, skillRow{owners: o, userACL: grant})
require.True(t, database.IsCheckViolation(err, database.CheckSkillsAclOnlyOnOrganizationSkills), "user ACL %+v: %v", o, err)
}
org := owners{org: valid(newOrg())}
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: org, groupACL: grant, userACL: grant}))
err := insertSkill(ctx, sqlDB, skillRow{owners: org, groupACL: "[]"})
require.True(t, database.IsCheckViolation(err, database.CheckSkillsGroupAclIsObject), err)
err = insertSkill(ctx, sqlDB, skillRow{owners: org, userACL: "[]"})
require.True(t, database.IsCheckViolation(err, database.CheckSkillsUserAclIsObject), err)
})
// TestUserSkillLimit and TestUserSkillLimitConcurrentCreates in coderd
// cover the user branch of the cap trigger.
t.Run("CapPerProject", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
full := owners{project: valid(newProject())}
for range skills.MaxPersonalSkillsPerUser {
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: full, enabled: true}))
}
err := insertSkill(ctx, sqlDB, skillRow{owners: full, enabled: true})
require.True(t, database.IsCheckViolation(err, "skills_per_project_limit"), err)
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: owners{project: valid(newProject())}, enabled: true}))
})
t.Run("ConcurrentOrganizationInsertsHonorCap", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
org := valid(newOrg())
for i := range skills.MaxPersonalSkillsPerUser - 1 {
// Disabled rows count toward the cap.
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: owners{org: org}, enabled: i%2 == 0}))
}
tx, err := sqlDB.BeginTx(ctx, nil)
require.NoError(t, err)
defer func() { _ = tx.Rollback() }()
require.NoError(t, insertSkill(ctx, tx, skillRow{owners: owners{org: org}, enabled: true}))
// Foreign key checks from unrelated inserts take KEY SHARE on the
// organization row, which the cap lock must not block.
_, err = sqlDB.ExecContext(ctx, "SELECT 1 FROM organizations WHERE id = $1 FOR KEY SHARE NOWAIT", org.UUID)
require.NoError(t, err)
concurrent := make(chan error, 1)
go func() {
concurrent <- insertSkill(ctx, sqlDB, skillRow{owners: owners{org: org}, enabled: true})
}()
// Commit only after the concurrent insert waits on the cap lock, so
// it cannot finish before the transaction by chance.
require.Eventually(t, func() bool {
if len(concurrent) > 0 {
return true
}
var waiting bool
err := sqlDB.QueryRowContext(ctx, `SELECT EXISTS (
SELECT 1 FROM pg_stat_activity
WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE '%INSERT INTO skills%'
)`).Scan(&waiting)
return err == nil && waiting
}, testutil.WaitShort, testutil.IntervalFast)
require.NoError(t, tx.Commit())
err = testutil.RequireReceive(ctx, t, concurrent)
require.True(t, database.IsCheckViolation(err, "skills_per_organization_limit"), err)
require.Equal(t, skills.MaxPersonalSkillsPerUser, countSkills(ctx, "organization_id", org.UUID))
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: owners{org: valid(newOrg())}, enabled: true}))
})
t.Run("NamesUniquePerOwner", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
const name = "shared-name"
user, org, otherOrg, project := valid(newUser()), valid(newOrg()), valid(newOrg()), valid(newProject())
for _, o := range []owners{{user: user}, {org: org}, {org: otherOrg}, {project: project}} {
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: o, name: name}))
}
err := insertSkill(ctx, sqlDB, skillRow{owners: owners{user: user}, name: name})
require.True(t, database.IsUniqueViolation(err, database.UniqueSkillsUserIDNameIndex), err)
err = insertSkill(ctx, sqlDB, skillRow{owners: owners{org: org}, name: name})
require.True(t, database.IsUniqueViolation(err, database.UniqueSkillsOrganizationIDNameIndex), err)
err = insertSkill(ctx, sqlDB, skillRow{owners: owners{project: project}, name: name})
require.True(t, database.IsUniqueViolation(err, database.UniqueSkillsProjectIDNameIndex), err)
})
t.Run("MemberRemovalDropsDirectGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
org, otherOrg := newOrg(), newOrg()
removed, kept := newUser(), newUser()
for _, o := range []uuid.UUID{org, otherOrg} {
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: o, UserID: removed})
grants := fmt.Sprintf(`{%q: {"permissions": ["read"]}, %q: {"permissions": ["read"]}}`, removed, kept)
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: owners{org: valid(o)}, userACL: grants}))
}
require.NoError(t, db.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{OrganizationID: org, UserID: removed}))
hasGrant := func(org, user uuid.UUID) bool {
t.Helper()
var has bool
err := sqlDB.QueryRowContext(ctx, "SELECT user_acl ? $2 FROM skills WHERE organization_id = $1", org, user.String()).Scan(&has)
require.NoError(t, err)
return has
}
require.False(t, hasGrant(org, removed))
require.True(t, hasGrant(org, kept))
require.True(t, hasGrant(otherOrg, removed))
})
t.Run("OwnerDeletionRemovesOnlyOwnRows", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
user, deletedUser := newUser(), newUser()
org, deletedOrg := newOrg(), newOrg()
project := dbgen.ChatProject(t, db, database.ChatProject{OrganizationID: org, OwnerID: deletedUser}).ID
deletedProject := dbgen.ChatProject(t, db, database.ChatProject{OrganizationID: org, OwnerID: deletedUser}).ID
for _, o := range []owners{
{user: valid(user)},
{user: valid(deletedUser)},
{org: valid(org)},
{org: valid(deletedOrg)},
{project: valid(project)},
{project: valid(deletedProject)},
} {
require.NoError(t, insertSkill(ctx, sqlDB, skillRow{owners: o}))
}
_, err := sqlDB.ExecContext(ctx, "UPDATE users SET deleted = true WHERE id = $1", deletedUser)
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, "DELETE FROM organizations WHERE id = $1", deletedOrg)
require.NoError(t, err)
require.NoError(t, db.DeleteChatProjectByID(ctx, deletedProject))
require.Equal(t, 1, countSkills(ctx, "user_id", user))
require.Equal(t, 0, countSkills(ctx, "user_id", deletedUser))
require.Equal(t, 1, countSkills(ctx, "organization_id", org))
require.Equal(t, 0, countSkills(ctx, "organization_id", deletedOrg))
// The soft-deleted user's project keeps its skills.
require.Equal(t, 1, countSkills(ctx, "project_id", project))
require.Equal(t, 0, countSkills(ctx, "project_id", deletedProject))
})
}
// TestOrganizationSkillQueries runs the organization skill queries that only
// execute against Postgres here: the SQL-filtered list, whose regosql
// converter must agree with the Rego ACL rules, and the update and delete
// statements.
func TestOrganizationSkillQueries(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := testutil.Context(t, testutil.WaitLong)
db, _ := dbtestutil.NewDB(t)
authzDB := dbauthz.New(db, rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), slogtest.Make(t, nil), coderdtest.AccessControlStorePointer())
org := dbgen.Organization(t, db, database.Organization{})
otherOrg := dbgen.Organization(t, db, database.Organization{})
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
newUser := func(siteRoles []string, orgID uuid.UUID, orgRoles ...string) uuid.UUID {
user := dbgen.User(t, db, database.User{RBACRoles: siteRoles})
if orgID != uuid.Nil {
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: orgID, Roles: orgRoles})
}
return user.ID
}
var (
siteOwner = newUser([]string{rbac.RoleOwner().String()}, uuid.Nil)
orgAdmin = newUser(nil, org.ID, rbac.RoleOrgAdmin())
groupMember = newUser(nil, org.ID)
grantedMember = newUser(nil, org.ID)
grantedOutside = newUser(nil, uuid.Nil)
)
dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: groupMember, GroupID: group.ID})
read := database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}}
orgSkill := func(groupACL, userACL database.ChatACL) database.Skill {
return dbgen.OrganizationSkill(t, db, database.Skill{
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
GroupACL: groupACL,
UserACL: userACL,
})
}
everyoneSkill := orgSkill(database.ChatACL{org.ID.String(): read}, nil)
groupSkill := orgSkill(database.ChatACL{group.ID.String(): read}, nil)
userSkill := orgSkill(nil, database.ChatACL{grantedMember.String(): read, grantedOutside.String(): read})
privateSkill := orgSkill(nil, nil)
dbgen.OrganizationSkill(t, db, database.Skill{
OrganizationID: uuid.NullUUID{UUID: otherOrg.ID, Valid: true},
GroupACL: database.ChatACL{otherOrg.ID.String(): read},
})
as := func(userID uuid.UUID) context.Context {
subject, _, err := httpmw.UserRBACSubject(ctx, db, userID, rbac.ExpandableScope(rbac.ScopeAll))
require.NoError(t, err)
return dbauthz.As(ctx, subject)
}
all := []uuid.UUID{everyoneSkill.ID, groupSkill.ID, userSkill.ID, privateSkill.ID}
for name, tc := range map[string]struct {
user uuid.UUID
want []uuid.UUID
}{
"SiteOwner": {siteOwner, all},
"OrgAdmin": {orgAdmin, all},
"GroupMember": {groupMember, []uuid.UUID{everyoneSkill.ID, groupSkill.ID}},
"GrantedMember": {grantedMember, []uuid.UUID{everyoneSkill.ID, userSkill.ID}},
"GrantedOutside": {grantedOutside, nil},
} {
rows, err := authzDB.ListOrganizationSkillMetadataByOrganizationID(as(tc.user), org.ID)
require.NoError(t, err, name)
got := make([]uuid.UUID, 0, len(rows))
for _, row := range rows {
got = append(got, row.ID)
}
require.ElementsMatch(t, tc.want, got, name)
}
updated, err := authzDB.UpdateOrganizationSkillByOrganizationIDAndName(as(orgAdmin), database.UpdateOrganizationSkillByOrganizationIDAndNameParams{
OrganizationID: org.ID, Name: groupSkill.Name, Enabled: sql.NullBool{Bool: false, Valid: true},
})
require.NoError(t, err)
require.False(t, updated.Enabled)
require.Equal(t, groupSkill.Content, updated.Content)
deleted, err := authzDB.DeleteOrganizationSkillByOrganizationIDAndName(as(orgAdmin), database.DeleteOrganizationSkillByOrganizationIDAndNameParams{
OrganizationID: org.ID, Name: groupSkill.Name,
})
require.NoError(t, err)
require.Equal(t, groupSkill.ID, deleted.ID)
}
+12
View File
@@ -335,6 +335,17 @@ var (
Type: "organization_member",
}
// ResourceOrganizationSkill
// Valid Actions
// - "ActionCreate" :: create an organization skill
// - "ActionDelete" :: delete an organization skill
// - "ActionRead" :: read organization skill metadata and content
// - "ActionShare" :: share an organization skill with other users or groups
// - "ActionUpdate" :: update an organization skill
ResourceOrganizationSkill = Object{
Type: "organization_skill",
}
// ResourcePrebuiltWorkspace
// Valid Actions
// - "ActionDelete" :: delete prebuilt workspace
@@ -565,6 +576,7 @@ func AllResources() []Objecter {
ResourceOauth2AppSecret,
ResourceOrganization,
ResourceOrganizationMember,
ResourceOrganizationSkill,
ResourcePrebuiltWorkspace,
ResourceProvisionerDaemon,
ResourceProvisionerJobs,
+11
View File
@@ -107,6 +107,14 @@ var chatModelConfigActions = map[Action]ActionDefinition{
ActionShare: "share a chat model config with other users or groups",
}
var organizationSkillActions = map[Action]ActionDefinition{
ActionCreate: "create an organization skill",
ActionRead: "read organization skill metadata and content",
ActionUpdate: "update an organization skill",
ActionDelete: "delete an organization skill",
ActionShare: "share an organization skill with other users or groups",
}
// RBACPermissions is indexed by the type
var RBACPermissions = map[string]PermissionDefinition{
// Wildcard is every object, and the action "*" provides all actions.
@@ -273,6 +281,9 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionDelete: "delete member",
},
},
"organization_skill": {
Actions: organizationSkillActions,
},
"debug_info": {
Actions: map[Action]ActionDefinition{
ActionRead: "access to debug routes",
+15
View File
@@ -87,6 +87,21 @@ func MCPServerConfigConverter() *sqltypes.VariableConverter {
return matcher
}
// OrganizationSkillConverter compiles organization skill authorization into
// a filter over the skills table.
func OrganizationSkillConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.StringVarMatcher("skills.organization_id :: text", []string{"input", "object", "org_owner"}),
sqltypes.AlwaysFalse(userOwnerMatcher()),
)
matcher.RegisterMatcher(
ACLMappingMatcher(matcher, "skills.group_acl", []string{"input", "object", "acl_group_list"}).UsingSubfield("permissions"),
ACLMappingMatcher(matcher, "skills.user_acl", []string{"input", "object", "acl_user_list"}).UsingSubfield("permissions"),
)
return matcher
}
func chatBaseConverter() *sqltypes.VariableConverter {
return sqltypes.NewVariableConverter().RegisterMatcher(
chatResourceIDMatcher(),
+5 -3
View File
@@ -475,9 +475,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
// Allow auditors to query deployment stats and insights.
ResourceDeploymentStats.Type: {policy.ActionRead},
ResourceDeploymentConfig.Type: {policy.ActionRead},
ResourceChatModelConfig.Type: {policy.ActionRead},
ResourceDeploymentStats.Type: {policy.ActionRead},
ResourceDeploymentConfig.Type: {policy.ActionRead},
ResourceChatModelConfig.Type: {policy.ActionRead},
ResourceOrganizationSkill.Type: {policy.ActionRead},
// Allow auditors to query AI Bridge interceptions.
ResourceAibridgeInterception.Type: {policy.ActionRead},
// Allow auditors to read boundary logs.
@@ -614,6 +615,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
ResourceChatModelConfig.Type: {policy.ActionRead},
ResourceOrganizationSkill.Type: {policy.ActionRead},
}),
Member: []Permission{},
},
+60
View File
@@ -1561,6 +1561,66 @@ func TestRolePermissions(t *testing.T) {
},
},
},
{
Name: "OrganizationSkillRead",
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceOrganizationSkill.WithID(uuid.New()).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, auditor, orgAuditor},
false: {
memberMe, orgMemberMe, orgWorkspaceAccessUser, orgAgentsAccessUser,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
userAdmin, orgUserAdmin, otherOrgUserAdmin,
otherOrgAdmin, otherOrgAuditor,
},
},
},
{
Name: "OrganizationSkillCreateUpdateDeleteShare",
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare},
Resource: rbac.ResourceOrganizationSkill.WithID(uuid.New()).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {
memberMe, orgMemberMe, orgWorkspaceAccessUser, orgAgentsAccessUser,
auditor, orgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
userAdmin, orgUserAdmin, otherOrgUserAdmin,
otherOrgAdmin, otherOrgAuditor,
},
},
},
{
// memberMe shares orgMemberMe's ID but is not an org member, so
// the user grant does not apply to it.
Name: "OrganizationSkillUserACLRead",
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceOrganizationSkill.WithID(uuid.New()).InOrg(orgID).WithACLUserList(map[string][]policy.Action{
currentUser.String(): {policy.ActionRead},
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, auditor, orgAuditor, orgMemberMe, orgWorkspaceAccessUser},
false: {
memberMe, orgUserAdmin, orgTemplateAdmin,
otherOrgAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
templateAdmin, userAdmin,
},
},
},
{
Name: "OrganizationSkillUserACLWrite",
Actions: []policy.Action{policy.ActionUpdate, policy.ActionDelete, policy.ActionShare},
Resource: rbac.ResourceOrganizationSkill.WithID(uuid.New()).InOrg(orgID).WithACLUserList(map[string][]policy.Action{
currentUser.String(): {policy.ActionRead},
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {
orgMemberMe, orgWorkspaceAccessUser, memberMe, auditor, orgAuditor,
orgUserAdmin, orgTemplateAdmin, setOtherOrg, templateAdmin, userAdmin,
},
},
},
{
Name: "ChatModelConfigCreateUpdateDeleteShare",
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare},
+8
View File
@@ -71,6 +71,14 @@ var externalLowLevel = map[ScopeName]struct{}{
"user_skill:delete": {},
"user_skill:*": {},
// Organization skills
"organization_skill:read": {},
"organization_skill:create": {},
"organization_skill:update": {},
"organization_skill:delete": {},
"organization_skill:share": {},
"organization_skill:*": {},
// Organizations
"organization:read": {},
"organization:update": {},
+15
View File
@@ -117,6 +117,11 @@ const (
ScopeOrganizationMemberDelete ScopeName = "organization_member:delete"
ScopeOrganizationMemberRead ScopeName = "organization_member:read"
ScopeOrganizationMemberUpdate ScopeName = "organization_member:update"
ScopeOrganizationSkillCreate ScopeName = "organization_skill:create"
ScopeOrganizationSkillDelete ScopeName = "organization_skill:delete"
ScopeOrganizationSkillRead ScopeName = "organization_skill:read"
ScopeOrganizationSkillShare ScopeName = "organization_skill:share"
ScopeOrganizationSkillUpdate ScopeName = "organization_skill:update"
ScopePrebuiltWorkspaceDelete ScopeName = "prebuilt_workspace:delete"
ScopePrebuiltWorkspaceUpdate ScopeName = "prebuilt_workspace:update"
ScopeProvisionerDaemonCreate ScopeName = "provisioner_daemon:create"
@@ -318,6 +323,11 @@ func (e ScopeName) Valid() bool {
ScopeOrganizationMemberDelete,
ScopeOrganizationMemberRead,
ScopeOrganizationMemberUpdate,
ScopeOrganizationSkillCreate,
ScopeOrganizationSkillDelete,
ScopeOrganizationSkillRead,
ScopeOrganizationSkillShare,
ScopeOrganizationSkillUpdate,
ScopePrebuiltWorkspaceDelete,
ScopePrebuiltWorkspaceUpdate,
ScopeProvisionerDaemonCreate,
@@ -520,6 +530,11 @@ func AllScopeNameValues() []ScopeName {
ScopeOrganizationMemberDelete,
ScopeOrganizationMemberRead,
ScopeOrganizationMemberUpdate,
ScopeOrganizationSkillCreate,
ScopeOrganizationSkillDelete,
ScopeOrganizationSkillRead,
ScopeOrganizationSkillShare,
ScopeOrganizationSkillUpdate,
ScopePrebuiltWorkspaceDelete,
ScopePrebuiltWorkspaceUpdate,
ScopeProvisionerDaemonCreate,
+9 -9
View File
@@ -31,8 +31,8 @@ const (
// These names are raised by trigger functions with USING CONSTRAINT.
// They are not table CHECK constraints, so dbgen does not emit them in
// check_constraint.go.
userSkillsPerUserLimitConstraint database.CheckConstraint = "user_skills_per_user_limit"
userSkillUserDeletedConstraint database.CheckConstraint = "user_skill_user_deleted"
skillsPerUserLimitConstraint database.CheckConstraint = "skills_per_user_limit"
userSkillUserDeletedConstraint database.CheckConstraint = "user_skill_user_deleted"
)
// @Summary Create a user skill
@@ -51,7 +51,7 @@ func (api *API) postUserSkill(rw http.ResponseWriter, r *http.Request) {
ctx = r.Context()
user = httpmw.UserParam(r)
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSkill](rw, &audit.RequestParams{
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
@@ -92,11 +92,11 @@ func (api *API) postUserSkill(rw http.ResponseWriter, r *http.Request) {
httpapi.ResourceNotFound(rw)
return
}
if database.IsCheckViolation(err, userSkillsPerUserLimitConstraint) {
if database.IsCheckViolation(err, skillsPerUserLimitConstraint) {
writeUserSkillLimitReached(ctx, rw)
return
}
if database.IsUniqueViolation(err, database.UniqueUserSkillsUserIDNameIndex) {
if database.IsUniqueViolation(err, database.UniqueSkillsUserIDNameIndex) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "A skill with that name already exists.",
Detail: err.Error(),
@@ -186,7 +186,7 @@ func (api *API) patchUserSkill(rw http.ResponseWriter, r *http.Request) {
user = httpmw.UserParam(r)
name = chi.URLParam(r, "skillName")
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSkill](rw, &audit.RequestParams{
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
@@ -221,8 +221,8 @@ func (api *API) patchUserSkill(rw http.ResponseWriter, r *http.Request) {
}
var (
skill database.UserSkill
oldSkill database.UserSkill
skill database.Skill
oldSkill database.Skill
)
err = api.Database.InTx(func(tx database.Store) error {
fetched, err := tx.GetUserSkillByUserIDAndName(ctx, database.GetUserSkillByUserIDAndNameParams{
@@ -281,7 +281,7 @@ func (api *API) deleteUserSkill(rw http.ResponseWriter, r *http.Request) {
user = httpmw.UserParam(r)
name = chi.URLParam(r, "skillName")
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSkill](rw, &audit.RequestParams{
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
+7 -7
View File
@@ -474,7 +474,7 @@ func TestUserSkillDatabaseConstraints(t *testing.T) {
Description: "Invalid",
Content: userSkillMarkdown("not kebab", "Invalid", "Body."),
},
constraint: database.CheckUserSkillsNameFormat,
constraint: database.CheckSkillsNameFormat,
},
{
name: "NameSize",
@@ -485,7 +485,7 @@ func TestUserSkillDatabaseConstraints(t *testing.T) {
Description: "Invalid",
Content: userSkillMarkdown("too-long-name", "Invalid", "Body."),
},
constraint: database.CheckUserSkillsNameSize,
constraint: database.CheckSkillsNameSize,
},
{
name: "ContentSize",
@@ -496,7 +496,7 @@ func TestUserSkillDatabaseConstraints(t *testing.T) {
Description: "Invalid",
Content: strings.Repeat("a", skills.MaxPersonalSkillSizeBytes+1),
},
constraint: database.CheckUserSkillsContentSize,
constraint: database.CheckSkillsContentSize,
},
}
for _, tt := range tests {
@@ -521,14 +521,14 @@ func TestUserSkillSchemaConstants(t *testing.T) {
var triggerDef string
require.NoError(t, sqlDB.QueryRowContext(
ctx,
`SELECT pg_get_functiondef('enforce_user_skills_per_user_limit'::regproc)`,
`SELECT pg_get_functiondef('enforce_skills_per_owner_limit'::regproc)`,
).Scan(&triggerDef))
assert.Contains(t, triggerDef, fmt.Sprintf("skill_limit constant int := %d;", skills.MaxPersonalSkillsPerUser))
constraints := map[database.CheckConstraint]string{
database.CheckUserSkillsNameSize: fmt.Sprintf("octet_length(name) <= %d", skills.MaxPersonalSkillNameBytes),
database.CheckUserSkillsNameFormat: "name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text",
database.CheckUserSkillsContentSize: fmt.Sprintf("octet_length(content) <= %d", skills.MaxPersonalSkillSizeBytes),
database.CheckSkillsNameSize: fmt.Sprintf("octet_length(name) <= %d", skills.MaxPersonalSkillNameBytes),
database.CheckSkillsNameFormat: "name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text",
database.CheckSkillsContentSize: fmt.Sprintf("octet_length(content) <= %d", skills.MaxPersonalSkillSizeBytes),
}
for constraint, expected := range constraints {
t.Run(string(constraint), func(t *testing.T) {
+11 -11
View File
@@ -2078,7 +2078,7 @@ func TestFetchPersonalSkillMetadata(t *testing.T) {
requireUserSkillContextActor(ctx, t, userID)
require.Equal(t, userID, gotUserID)
return []database.ListUserSkillMetadataByUserIDRow{{
UserID: userID,
UserID: uuid.NullUUID{UUID: userID, Valid: true},
Name: "personal-review",
Description: "Personal review process",
}}, nil
@@ -2126,11 +2126,11 @@ func TestLoadPersonalSkillBody(t *testing.T) {
}
db.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), params).DoAndReturn(
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.Skill, error) {
requireUserSkillContextActor(ctx, t, userID)
require.Equal(t, params, gotParams)
return database.UserSkill{
UserID: userID,
return database.Skill{
UserID: uuid.NullUUID{UUID: userID, Valid: true},
Name: "personal-review",
Content: "---\nname: personal-review\ndescription: Personal review process\n---\n\nUpdated instructions.\n",
}, nil
@@ -2158,10 +2158,10 @@ func TestLoadPersonalSkillBody(t *testing.T) {
}
db.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), params).DoAndReturn(
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.Skill, error) {
requireUserSkillContextActor(ctx, t, userID)
require.Equal(t, params, gotParams)
return database.UserSkill{}, sql.ErrNoRows
return database.Skill{}, sql.ErrNoRows
},
)
@@ -2184,10 +2184,10 @@ func TestLoadPersonalSkillBody(t *testing.T) {
dbErr := xerrors.New("database unavailable")
db.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), params).DoAndReturn(
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.Skill, error) {
requireUserSkillContextActor(ctx, t, userID)
require.Equal(t, params, gotParams)
return database.UserSkill{}, dbErr
return database.Skill{}, dbErr
},
)
@@ -2216,11 +2216,11 @@ func TestLoadPersonalSkillBody(t *testing.T) {
}
db.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), params).DoAndReturn(
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
func(ctx context.Context, gotParams database.GetUserSkillByUserIDAndNameParams) (database.Skill, error) {
requireUserSkillContextActor(ctx, t, userID)
require.Equal(t, params, gotParams)
return database.UserSkill{
UserID: userID,
return database.Skill{
UserID: uuid.NullUUID{UUID: userID, Valid: true},
Name: "broken-skill",
Content: "---\nname: broken-skill\ndescription: Broken\n---\n\n \n",
}, nil
+12
View File
@@ -160,6 +160,12 @@ const (
APIKeyScopeOrganizationMemberDelete APIKeyScope = "organization_member:delete"
APIKeyScopeOrganizationMemberRead APIKeyScope = "organization_member:read"
APIKeyScopeOrganizationMemberUpdate APIKeyScope = "organization_member:update"
APIKeyScopeOrganizationSkillAll APIKeyScope = "organization_skill:*"
APIKeyScopeOrganizationSkillCreate APIKeyScope = "organization_skill:create"
APIKeyScopeOrganizationSkillDelete APIKeyScope = "organization_skill:delete"
APIKeyScopeOrganizationSkillRead APIKeyScope = "organization_skill:read"
APIKeyScopeOrganizationSkillShare APIKeyScope = "organization_skill:share"
APIKeyScopeOrganizationSkillUpdate APIKeyScope = "organization_skill:update"
APIKeyScopePrebuiltWorkspaceAll APIKeyScope = "prebuilt_workspace:*"
APIKeyScopePrebuiltWorkspaceDelete APIKeyScope = "prebuilt_workspace:delete"
APIKeyScopePrebuiltWorkspaceUpdate APIKeyScope = "prebuilt_workspace:update"
@@ -287,6 +293,12 @@ var PublicAPIKeyScopes = []APIKeyScope{
APIKeyScopeOrganizationDelete,
APIKeyScopeOrganizationRead,
APIKeyScopeOrganizationUpdate,
APIKeyScopeOrganizationSkillAll,
APIKeyScopeOrganizationSkillCreate,
APIKeyScopeOrganizationSkillDelete,
APIKeyScopeOrganizationSkillRead,
APIKeyScopeOrganizationSkillShare,
APIKeyScopeOrganizationSkillUpdate,
APIKeyScopeTemplateAll,
APIKeyScopeTemplateCreate,
APIKeyScopeTemplateDelete,
+3
View File
@@ -64,6 +64,7 @@ const (
ResourceTypeChatAutomation ResourceType = "chat_automation"
ResourceTypeUserSecret ResourceType = "user_secret"
ResourceTypeUserSkill ResourceType = "user_skill"
ResourceTypeOrganizationSkill ResourceType = "organization_skill"
ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings"
ResourceTypeChatOperationalSettings ResourceType = "chat_operational_settings"
ResourceTypeChatOrganizationSystemPrompt ResourceType = "chat_organization_system_prompt"
@@ -157,6 +158,8 @@ func (r ResourceType) FriendlyString() string {
return "user secret"
case ResourceTypeUserSkill:
return "user skill"
case ResourceTypeOrganizationSkill:
return "organization skill"
case ResourceTypeChatInstructionSettings:
return "chat instruction settings"
case ResourceTypeChatOperationalSettings:
+2
View File
@@ -40,6 +40,7 @@ const (
ResourceOauth2AppSecret RBACResource = "oauth2_app_secret"
ResourceOrganization RBACResource = "organization"
ResourceOrganizationMember RBACResource = "organization_member"
ResourceOrganizationSkill RBACResource = "organization_skill"
ResourcePrebuiltWorkspace RBACResource = "prebuilt_workspace"
ResourceProvisionerDaemon RBACResource = "provisioner_daemon"
ResourceProvisionerJobs RBACResource = "provisioner_jobs"
@@ -122,6 +123,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOrganizationSkill: {ActionCreate, ActionDelete, ActionRead, ActionShare, ActionUpdate},
ResourcePrebuiltWorkspace: {ActionDelete, ActionUpdate},
ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceProvisionerJobs: {ActionCreate, ActionRead, ActionUpdate},
+22 -17
View File
@@ -649,6 +649,28 @@ Actions: `create`, `write`, `delete`
</tbody>
</table>
### Skill
Actions: `create`, `write`, `delete`
<table width="100%">
<thead><tr><th width="75%">Field</th><th width="25%">Tracked</th></tr></thead>
<tbody>
<tr><td><code>content</code></td><td>Yes</td></tr>
<tr><td><code>created_at</code></td><td>No</td></tr>
<tr><td><code>description</code></td><td>Yes</td></tr>
<tr><td><code>enabled</code></td><td>Yes</td></tr>
<tr><td><code>group_acl</code></td><td>Yes</td></tr>
<tr><td><code>id</code></td><td>Yes</td></tr>
<tr><td><code>name</code></td><td>Yes</td></tr>
<tr><td><code>organization_id</code></td><td>Yes</td></tr>
<tr><td><code>project_id</code></td><td>Yes</td></tr>
<tr><td><code>updated_at</code></td><td>No</td></tr>
<tr><td><code>user_acl</code></td><td>Yes</td></tr>
<tr><td><code>user_id</code></td><td>Yes</td></tr>
</tbody>
</table>
### Template
Actions: `write`, `delete`
@@ -777,23 +799,6 @@ Actions: `create`, `write`, `delete`
</tbody>
</table>
### UserSkill
Actions: `create`, `write`, `delete`
<table width="100%">
<thead><tr><th width="75%">Field</th><th width="25%">Tracked</th></tr></thead>
<tbody>
<tr><td><code>content</code></td><td>Yes</td></tr>
<tr><td><code>created_at</code></td><td>No</td></tr>
<tr><td><code>description</code></td><td>Yes</td></tr>
<tr><td><code>id</code></td><td>Yes</td></tr>
<tr><td><code>name</code></td><td>Yes</td></tr>
<tr><td><code>updated_at</code></td><td>No</td></tr>
<tr><td><code>user_id</code></td><td>Yes</td></tr>
</tbody>
</table>
### WorkspaceBuild
Actions: `start`, `stop`
+11
View File
@@ -137,6 +137,17 @@ The `resource:*` form grants every action on that resource, including actions no
| `organization:read` | Read organizations. |
| `organization:update` | Update an organization. |
### `organization_skill`
| Scope | Description |
|-----------------------------|----------------------------------------------------------------------------------|
| `organization_skill:*` | Every action on `organization_skill`, including actions not listed on this page. |
| `organization_skill:create` | Create an organization skill. |
| `organization_skill:delete` | Delete an organization skill. |
| `organization_skill:read` | Read organization skill metadata and content. |
| `organization_skill:share` | Share an organization skill with other users or groups. |
| `organization_skill:update` | Update an organization skill. |
### `template`
| Scope | Description |
+20 -20
View File
@@ -198,10 +198,10 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `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`, `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` |
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`, `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`, `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` |
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`, `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`, `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` |
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`, `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`, `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` |
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`, `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`, `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` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+9 -9
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -819,11 +819,11 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `chat_project`, `chat_project_memory`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `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` |
| `login_type` | `github`, `oidc`, `password`, `token` |
| `scope` | `all`, `application_connect` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+4 -4
View File
@@ -360,17 +360,17 @@ func Test_diff(t *testing.T) {
// User skill content is user-authored instruction text, not secret
// material, so audit diffs can include the content change.
name: "UserSkillContentTracked",
left: audit.Empty[database.UserSkill](),
right: database.UserSkill{
left: audit.Empty[database.Skill](),
right: database.Skill{
ID: uuid.UUID{1},
UserID: uuid.UUID{2},
UserID: uuid.NullUUID{UUID: uuid.UUID{2}, Valid: true},
Name: "review-guidance",
Description: "How to review private projects",
Content: "review markdown",
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"user_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
"user_id": audit.OldNew{Old: "null", New: uuid.UUID{2}.String()},
"name": audit.OldNew{Old: "", New: "review-guidance"},
"description": audit.OldNew{Old: "", New: "How to review private projects"},
"content": audit.OldNew{Old: "", New: "review markdown"},
+14 -9
View File
@@ -40,7 +40,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
"ChatModelConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"MCPServerConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"Skill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"ChatInstructionSettings": {codersdk.AuditActionWrite},
"ChatOperationalSettings": {codersdk.AuditActionWrite},
"ChatOrganizationSystemPrompt": {codersdk.AuditActionWrite},
@@ -590,14 +590,19 @@ var auditableResourcesTypes = map[any]map[string]Action{
"oauth2_revocation_url": ActionTrack,
"organization_id": ActionIgnore,
},
&database.UserSkill{}: {
"id": ActionTrack,
"user_id": ActionTrack,
"name": ActionTrack,
"description": ActionTrack,
"content": ActionTrack,
"created_at": ActionIgnore,
"updated_at": ActionIgnore,
&database.Skill{}: {
"id": ActionTrack,
"user_id": ActionTrack,
"organization_id": ActionTrack,
"project_id": ActionTrack,
"name": ActionTrack,
"description": ActionTrack,
"content": ActionTrack,
"enabled": ActionTrack,
"group_acl": ActionTrack,
"user_acl": ActionTrack,
"created_at": ActionIgnore,
"updated_at": ActionIgnore,
},
&database.ChatOperationalSettings{}: {
"id": ActionIgnore,
+7
View File
@@ -188,6 +188,13 @@ export const RBACResourceActions: Partial<
read: "read member",
update: "update an organization member",
},
organization_skill: {
create: "create an organization skill",
delete: "delete an organization skill",
read: "read organization skill metadata and content",
share: "share an organization skill with other users or groups",
update: "update an organization skill",
},
prebuilt_workspace: {
delete: "delete prebuilt workspace",
update: "update prebuilt workspace settings",
+16
View File
@@ -862,6 +862,12 @@ export type APIKeyScope =
| "organization_member:read"
| "organization_member:update"
| "organization:read"
| "organization_skill:*"
| "organization_skill:create"
| "organization_skill:delete"
| "organization_skill:read"
| "organization_skill:share"
| "organization_skill:update"
| "organization:update"
| "prebuilt_workspace:*"
| "prebuilt_workspace:delete"
@@ -1118,6 +1124,12 @@ export const APIKeyScopes: APIKeyScope[] = [
"organization_member:read",
"organization_member:update",
"organization:read",
"organization_skill:*",
"organization_skill:create",
"organization_skill:delete",
"organization_skill:read",
"organization_skill:share",
"organization_skill:update",
"organization:update",
"prebuilt_workspace:*",
"prebuilt_workspace:delete",
@@ -8812,6 +8824,7 @@ export type RBACResource =
| "oauth2_app_secret"
| "organization"
| "organization_member"
| "organization_skill"
| "prebuilt_workspace"
| "provisioner_daemon"
| "provisioner_jobs"
@@ -8868,6 +8881,7 @@ export const RBACResources: RBACResource[] = [
"oauth2_app_secret",
"organization",
"organization_member",
"organization_skill",
"prebuilt_workspace",
"provisioner_daemon",
"provisioner_jobs",
@@ -9027,6 +9041,7 @@ export type ResourceType =
| "oauth2_provider_settings"
| "organization"
| "organization_member"
| "organization_skill"
| "prebuilds_settings"
| "task"
| "template"
@@ -9075,6 +9090,7 @@ export const ResourceTypes: ResourceType[] = [
"oauth2_provider_settings",
"organization",
"organization_member",
"organization_skill",
"prebuilds_settings",
"task",
"template",