mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## 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 2. **#30610** organization skill API routes and the scope-neutral SDK (this PR) 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 organization skill CRUD routes and makes the skill SDK types owner-neutral so personal and organization skills share them. **API contract change (experimental API, personal and organization skills):** skill list and get responses gain `enabled`, and `PATCH .../skills/{skillName}` now takes `{content?, enabled?}`, so `content` is optional and a toggle doesn't resend it. A PATCH with neither field returns 400; `content`, when present, is validated as before. In the SDK, `UserSkill`, `UserSkillMetadata`, `CreateUserSkillRequest` and `UpdateUserSkillRequest` become `Skill`, `SkillMetadata`, `CreateSkillRequest` and `UpdateSkillRequest` (`codersdk/userskills.go` moves to `codersdk/skills.go`), and `UpdateSkillRequest.Content` is now `*string`. The personal client method names are unchanged. The site changes are the handwritten edits that rename forces (`api.ts`, `queries/userSkills.ts`, `testHelpers/skills.ts` and three call sites), with no UI change. Organization routes under `/api/experimental/organizations/{organization}/skills`, not gated by an experiment or license, like the personal ones: - `GET` lists with the caller's own permissions, so admins, the site owner and org auditors see every skill and members see only skills shared with them. `GET /{skillName}` goes through the new `httpmw.ExtractOrganizationSkillParam` and admits only read, so a missing skill and a denied one both return 404. - `POST` checks `create` on the organization before reading the body, then seeds `group_acl` with the Everyone group (whose ID is the organization ID) so a new skill reaches every member until an admin narrows it. The 100-skill cap and a duplicate name return 409. - `PATCH` admits only callers who can read the skill, because its response includes the content; `DELETE` admits callers who can read or delete it. A reader who can't change the skill gets a write-specific 403 from the database wrapper, and everyone else gets 404. PATCH locks the row as system, updates as the caller in the same transaction, and audits the locked row only after the transaction commits. - Audit: `database.AuditableOrganizationSkill` records resource type `organization_skill` with the organization ID; personal skills keep `user_skill`. Personal and organization handlers share one create and update path in `coderd/userskills.go` (`createSkill`, `updateSkill`, and a `skillWriteErrors` table per owner and action). ## Commits The first commit reproduces the reviewed code from the old stack; each later commit is a labeled trim or fix. - `49cb2c84276` feat: add organization skill API routes. Reproduction: every file this commit changes, including the SDK layer's site edits, equals the reviewed old `write-routes` head `aa597100f28`; the rest of the tree differs only by #30609's migration merge and main. - `dc893bc60d6` refactor(coderd): share the skill create and update path across owners (production +164/-188). It keeps the organization create check before the body read, the lock as system then update as caller order, and audit assignment after the transaction. No swagger, `codersdk` or `typesGenerated.ts` change; `make gen` is clean. - `f94925425ef` test(coderd): merge duplicate personal and organization skill tests. `TestSkillsCRUD`, `TestSkillValidationAndConflicts` and `TestSkillAudit` are table-driven over Personal, OrgAdmin and SiteOwner, alongside `TestOrganizationSkillAuditsDeniedWrites` and one `ScopedAPIKeys` subtest; `TestPatchUserSkill` is deleted because none of its blocks were unique. Coverage of `userskills.go`, `organizationskills.go` and `httpmw/organizationskillparam.go` is 101 of 117 blocks before and after, the same block set. - `ad0a7a37504` fix(coderd): require read access to patch an organization skill. The organization PATCH route now admits only `read`, because its response carries the skill's content: a key scoped to `organization_skill:update` without read gets 404, like GET, instead of 200 with the content. Readers without update still get 403, and DELETE is unchanged. `TestOrganizationSkillAccess/ScopedAPIKeys` asserts the 404, that the response has no content, and that the skill is unchanged; against the old route it fails with 200. - `69d00ded0b6` and `f67b79343ec` merge the updated #30609 layer, including main; `69d00ded0b6` resolves a conflict in the personal skills page with main. No other changes of their own. - `18373ffd59e` merges the updated #30609 layer (main at `e7b76872098`) and reapplies the `AuditableOrganizationSkill` entry in `enterprise/audit/table.go` alongside main's new `AuditableChatProjectInstructions` entries. ## Size Production +838/-308, tests +733/-322, generated +801/-274; total +2372/-904. 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 #30587 (scope-neutral SDK and personal enabled state), #30588 (read routes), #30589 (write routes and audit). ## Validation - The coverage comparison above, before and after the test merge. - `make gen` leaves no diff after the shared create and update refactor. - On `18373ffd59e`: `make gen` is clean; build, vet, the `coderd` and `enterprise/coderd` skill tests, `rbac`, `httpmw`, `dbauthz` and `enterprise/audit` pass; `pnpm check`, `pnpm lint` and `make pre-commit` pass. - 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.
1088 lines
35 KiB
Go
1088 lines
35 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/util/slice"
|
|
)
|
|
|
|
type WorkspaceStatus string
|
|
|
|
const (
|
|
WorkspaceStatusPending WorkspaceStatus = "pending"
|
|
WorkspaceStatusStarting WorkspaceStatus = "starting"
|
|
WorkspaceStatusRunning WorkspaceStatus = "running"
|
|
WorkspaceStatusStopping WorkspaceStatus = "stopping"
|
|
WorkspaceStatusStopped WorkspaceStatus = "stopped"
|
|
WorkspaceStatusFailed WorkspaceStatus = "failed"
|
|
WorkspaceStatusCanceling WorkspaceStatus = "canceling"
|
|
WorkspaceStatusCanceled WorkspaceStatus = "canceled"
|
|
WorkspaceStatusDeleting WorkspaceStatus = "deleting"
|
|
WorkspaceStatusDeleted WorkspaceStatus = "deleted"
|
|
)
|
|
|
|
func (s WorkspaceStatus) Valid() bool {
|
|
switch s {
|
|
case WorkspaceStatusPending, WorkspaceStatusStarting, WorkspaceStatusRunning,
|
|
WorkspaceStatusStopping, WorkspaceStatusStopped, WorkspaceStatusFailed,
|
|
WorkspaceStatusCanceling, WorkspaceStatusCanceled, WorkspaceStatusDeleting,
|
|
WorkspaceStatusDeleted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type WorkspaceAgentStatus string
|
|
|
|
// This is also in codersdk/workspaceagents.go and should be kept in sync.
|
|
const (
|
|
WorkspaceAgentStatusConnecting WorkspaceAgentStatus = "connecting"
|
|
WorkspaceAgentStatusConnected WorkspaceAgentStatus = "connected"
|
|
WorkspaceAgentStatusDisconnected WorkspaceAgentStatus = "disconnected"
|
|
WorkspaceAgentStatusTimeout WorkspaceAgentStatus = "timeout"
|
|
)
|
|
|
|
func (s WorkspaceAgentStatus) Valid() bool {
|
|
switch s {
|
|
case WorkspaceAgentStatusConnecting, WorkspaceAgentStatusConnected,
|
|
WorkspaceAgentStatusDisconnected, WorkspaceAgentStatusTimeout:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type AuditableOrganizationMember struct {
|
|
OrganizationMember
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
func (m OrganizationMember) Auditable(username string) AuditableOrganizationMember {
|
|
return AuditableOrganizationMember{
|
|
OrganizationMember: m,
|
|
Username: username,
|
|
}
|
|
}
|
|
|
|
// AuditableOrganizationSkill is the audit-log representation of an
|
|
// organization skill. It is a separate type because audit decides per Go type
|
|
// whether a resource requires an organization ID.
|
|
type AuditableOrganizationSkill struct {
|
|
Skill
|
|
}
|
|
|
|
type AuditableGroup struct {
|
|
Group
|
|
Members []GroupMemberTable `json:"members"`
|
|
}
|
|
|
|
// AuditableGroupAIBudget is the audit-log representation of GroupAIBudget.
|
|
// It enriches the raw record with the group's name and a human-readable
|
|
// spend limit so audit entries can display meaningful values instead of
|
|
// UUIDs and micros.
|
|
type AuditableGroupAIBudget struct {
|
|
GroupAIBudget
|
|
GroupName string `json:"group_name"`
|
|
SpendLimit string `json:"spend_limit"`
|
|
}
|
|
|
|
func (b GroupAIBudget) Auditable(groupName string) AuditableGroupAIBudget {
|
|
return AuditableGroupAIBudget{
|
|
GroupAIBudget: b,
|
|
GroupName: groupName,
|
|
SpendLimit: fmt.Sprintf("$%.2f", float64(b.SpendLimitMicros)/1_000_000),
|
|
}
|
|
}
|
|
|
|
// AuditableUserAIBudgetOverride is the audit-log representation of
|
|
// UserAIBudgetOverride. It enriches the raw record with the username, the
|
|
// attributed group's name, and a human-readable spend limit so audit
|
|
// entries can display meaningful values instead of UUIDs and micros.
|
|
type AuditableUserAIBudgetOverride struct {
|
|
UserAIBudgetOverride
|
|
Username string `json:"username"`
|
|
GroupName string `json:"group_name"`
|
|
SpendLimit string `json:"spend_limit"`
|
|
}
|
|
|
|
func (o UserAIBudgetOverride) Auditable(username, groupName string) AuditableUserAIBudgetOverride {
|
|
return AuditableUserAIBudgetOverride{
|
|
UserAIBudgetOverride: o,
|
|
Username: username,
|
|
GroupName: groupName,
|
|
SpendLimit: fmt.Sprintf("$%.2f", float64(o.SpendLimitMicros)/1_000_000),
|
|
}
|
|
}
|
|
|
|
// AuditableChatProjectInstructions is the audit-log representation of
|
|
// ChatProjectInstruction. It adds the project's name so audit entries name
|
|
// the project instead of only its UUID.
|
|
type AuditableChatProjectInstructions struct {
|
|
ChatProjectInstruction
|
|
ProjectName string `json:"project_name"`
|
|
}
|
|
|
|
// Auditable returns the audit-log representation of the instructions for
|
|
// the named project.
|
|
func (i ChatProjectInstruction) Auditable(projectName string) AuditableChatProjectInstructions {
|
|
return AuditableChatProjectInstructions{
|
|
ChatProjectInstruction: i,
|
|
ProjectName: projectName,
|
|
}
|
|
}
|
|
|
|
// Auditable returns an object that can be used in audit logs.
|
|
// Covers both group and group member changes.
|
|
func (g Group) Auditable(members []GroupMember) AuditableGroup {
|
|
membersTable := make([]GroupMemberTable, len(members))
|
|
for i, member := range members {
|
|
membersTable[i] = GroupMemberTable{
|
|
UserID: member.UserID,
|
|
GroupID: member.GroupID,
|
|
}
|
|
}
|
|
|
|
// consistent ordering
|
|
sort.Slice(members, func(i, j int) bool {
|
|
return members[i].UserID.String() < members[j].UserID.String()
|
|
})
|
|
|
|
return AuditableGroup{
|
|
Group: g,
|
|
Members: membersTable,
|
|
}
|
|
}
|
|
|
|
const EveryoneGroup = "Everyone"
|
|
|
|
func (w GetAuditLogsOffsetRow) RBACObject() rbac.Object {
|
|
return w.AuditLog.RBACObject()
|
|
}
|
|
|
|
func (w AuditLog) RBACObject() rbac.Object {
|
|
obj := rbac.ResourceAuditLog.WithID(w.ID)
|
|
if w.OrganizationID != uuid.Nil {
|
|
obj = obj.InOrg(w.OrganizationID)
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
func (w GetConnectionLogsOffsetRow) RBACObject() rbac.Object {
|
|
return w.ConnectionLog.RBACObject()
|
|
}
|
|
|
|
func (w ConnectionLog) RBACObject() rbac.Object {
|
|
obj := rbac.ResourceConnectionLog.WithID(w.ID)
|
|
if w.OrganizationID != uuid.Nil {
|
|
obj = obj.InOrg(w.OrganizationID)
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
func (p ChatProject) RBACObject() rbac.Object {
|
|
return rbac.ResourceChatProject.WithID(p.ID).InOrg(p.OrganizationID).WithOwner(p.OwnerID.String())
|
|
}
|
|
|
|
// RBACObject scopes a memory to its project so the project owner owns it.
|
|
// Memories carry no owner of their own, so the parent project must be
|
|
// supplied.
|
|
func (m ChatProjectMemory) RBACObject(project ChatProject) rbac.Object {
|
|
return rbac.ResourceChatProjectMemory.WithID(m.ID).InOrg(project.OrganizationID).WithOwner(project.OwnerID.String())
|
|
}
|
|
|
|
// ChatProjectMemoryRBACObject is the object to authorize when creating a
|
|
// memory in the project, before an ID exists.
|
|
func ChatProjectMemoryRBACObject(project ChatProject) rbac.Object {
|
|
return rbac.ResourceChatProjectMemory.InOrg(project.OrganizationID).WithOwner(project.OwnerID.String())
|
|
}
|
|
|
|
func (c Chat) RBACObject() rbac.Object {
|
|
obj := rbac.ResourceChat.
|
|
WithID(c.ID).
|
|
WithOwner(c.OwnerID.String()).
|
|
InOrg(c.OrganizationID)
|
|
|
|
if rbac.ChatACLDisabled() {
|
|
return obj
|
|
}
|
|
|
|
return obj.
|
|
WithACLUserList(c.UserACL.RBACACL()).
|
|
WithGroupACL(c.GroupACL.RBACACL())
|
|
}
|
|
|
|
func (m MCPServerConfig) RBACObject() rbac.Object {
|
|
return rbac.ResourceMCPServerConfig.
|
|
WithID(m.ID).
|
|
InOrg(m.OrganizationID).
|
|
WithGroupACL(m.GroupACL.RBACACL()).
|
|
WithACLUserList(m.UserACL.RBACACL())
|
|
}
|
|
|
|
func (c Chat) IsSubChat() bool {
|
|
return c.RootChatID.Valid || c.ParentChatID.Valid
|
|
}
|
|
|
|
func (r GetChatsRow) RBACObject() rbac.Object {
|
|
return r.Chat.RBACObject()
|
|
}
|
|
|
|
func (r GetChildChatsByParentIDsRow) RBACObject() rbac.Object {
|
|
return r.Chat.RBACObject()
|
|
}
|
|
|
|
func (c ChatFile) RBACObject() rbac.Object {
|
|
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
|
|
}
|
|
|
|
func (c GetChatFileMetadataByChatIDRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
|
|
}
|
|
|
|
func (c GetChatFileDataPrefixesByIDsRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
|
|
}
|
|
|
|
func (s APIKeyScope) ToRBAC() rbac.ScopeName {
|
|
switch s {
|
|
case ApiKeyScopeCoderAll:
|
|
return rbac.ScopeAll
|
|
case ApiKeyScopeCoderApplicationConnect:
|
|
return rbac.ScopeApplicationConnect
|
|
default:
|
|
// Allow low-level resource:action scopes to flow through to RBAC for
|
|
// expansion via rbac.ExpandScope.
|
|
return rbac.ScopeName(s)
|
|
}
|
|
}
|
|
|
|
// APIKeyScopes represents a collection of individual API key scope names as
|
|
// stored in the database. Helper methods on this type are used to derive the
|
|
// RBAC scope that should be authorized for the key.
|
|
type APIKeyScopes []APIKeyScope
|
|
|
|
// WithAllowList wraps the scopes with a database allow list, producing an
|
|
// ExpandableScope that always enforces the allow list overlay when expanded.
|
|
func (s APIKeyScopes) WithAllowList(list AllowList) APIKeyScopeSet {
|
|
return APIKeyScopeSet{Scopes: s, AllowList: list}
|
|
}
|
|
|
|
// Has returns true if the slice contains the provided scope.
|
|
func (s APIKeyScopes) Has(target APIKeyScope) bool {
|
|
return slices.Contains(s, target)
|
|
}
|
|
|
|
// expandRBACScope merges the permissions of all scopes in the list into a
|
|
// single RBAC scope. An empty list is an error rather than rbac.ScopeAll, which
|
|
// would widen a key rather than fail it. This method is internal; use
|
|
// ScopeSet() to combine scopes with the API key's allow list for authorization.
|
|
func (s APIKeyScopes) expandRBACScope() (rbac.Scope, error) {
|
|
if len(s) == 0 {
|
|
return rbac.Scope{}, xerrors.New("no scopes provided")
|
|
}
|
|
|
|
var merged rbac.Scope
|
|
merged.Role = rbac.Role{
|
|
// Identifier is informational; not used in policy evaluation.
|
|
Identifier: rbac.RoleIdentifier{Name: "Scope_Multiple"},
|
|
Site: nil,
|
|
User: nil,
|
|
ByOrgID: map[string]rbac.OrgPermissions{},
|
|
}
|
|
|
|
// Collect allow lists for a union after expanding all scopes.
|
|
allowLists := make([][]rbac.AllowListElement, 0, len(s))
|
|
|
|
for _, s := range s {
|
|
expanded, err := s.ToRBAC().Expand()
|
|
if err != nil {
|
|
return rbac.Scope{}, err
|
|
}
|
|
|
|
// Merge role permissions: union by simple concatenation.
|
|
merged.Site = append(merged.Site, expanded.Site...)
|
|
for orgID, perms := range expanded.ByOrgID {
|
|
orgPerms := merged.ByOrgID[orgID]
|
|
orgPerms.Org = append(orgPerms.Org, perms.Org...)
|
|
orgPerms.Member = append(orgPerms.Member, perms.Member...)
|
|
merged.ByOrgID[orgID] = orgPerms
|
|
}
|
|
merged.User = append(merged.User, expanded.User...)
|
|
|
|
allowLists = append(allowLists, expanded.AllowIDList)
|
|
}
|
|
|
|
// De-duplicate permissions across Site/Org/User
|
|
merged.Site = rbac.DeduplicatePermissions(merged.Site)
|
|
merged.User = rbac.DeduplicatePermissions(merged.User)
|
|
for orgID, perms := range merged.ByOrgID {
|
|
perms.Org = rbac.DeduplicatePermissions(perms.Org)
|
|
perms.Member = rbac.DeduplicatePermissions(perms.Member)
|
|
merged.ByOrgID[orgID] = perms
|
|
}
|
|
|
|
union, err := rbac.UnionAllowLists(allowLists...)
|
|
if err != nil {
|
|
return rbac.Scope{}, err
|
|
}
|
|
merged.AllowIDList = union
|
|
|
|
return merged, nil
|
|
}
|
|
|
|
// Name returns a human-friendly identifier for tracing/logging.
|
|
func (s APIKeyScopes) Name() rbac.RoleIdentifier {
|
|
if len(s) == 0 {
|
|
// Return all for backward compatibility.
|
|
return rbac.RoleIdentifier{Name: string(ApiKeyScopeCoderAll)}
|
|
}
|
|
names := make([]string, 0, len(s))
|
|
for _, s := range s {
|
|
names = append(names, string(s))
|
|
}
|
|
return rbac.RoleIdentifier{Name: "scopes[" + strings.Join(names, "+") + "]"}
|
|
}
|
|
|
|
// APIKeyScopeSet merges expanded scopes with the API key's DB allow_list. If
|
|
// the DB allow_list is a wildcard or empty, the merged scope's allow list is
|
|
// unchanged. Otherwise, the DB allow_list overrides the merged AllowIDList to
|
|
// enforce the token's resource scoping consistently across all permissions.
|
|
type APIKeyScopeSet struct {
|
|
Scopes APIKeyScopes
|
|
AllowList AllowList
|
|
}
|
|
|
|
var _ rbac.ExpandableScope = APIKeyScopeSet{}
|
|
|
|
func (s APIKeyScopeSet) Name() rbac.RoleIdentifier { return s.Scopes.Name() }
|
|
|
|
func (s APIKeyScopeSet) Expand() (rbac.Scope, error) {
|
|
merged, err := s.Scopes.expandRBACScope()
|
|
if err != nil {
|
|
return rbac.Scope{}, err
|
|
}
|
|
merged.AllowIDList = rbac.IntersectAllowLists(merged.AllowIDList, s.AllowList)
|
|
return merged, nil
|
|
}
|
|
|
|
// ScopeSet returns the scopes combined with the database allow list. It is the
|
|
// canonical way to expose an API key's effective scope for authorization.
|
|
func (k APIKey) ScopeSet() APIKeyScopeSet {
|
|
return APIKeyScopeSet{
|
|
Scopes: k.Scopes,
|
|
AllowList: k.AllowList,
|
|
}
|
|
}
|
|
|
|
func (k APIKey) RBACObject() rbac.Object {
|
|
return rbac.ResourceApiKey.WithIDString(k.ID).
|
|
WithOwner(k.UserID.String())
|
|
}
|
|
|
|
func (t Template) RBACObject() rbac.Object {
|
|
return rbac.ResourceTemplate.WithID(t.ID).
|
|
InOrg(t.OrganizationID).
|
|
WithACLUserList(t.UserACL).
|
|
WithGroupACL(t.GroupACL)
|
|
}
|
|
|
|
func (t GetFileTemplatesRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceTemplate.WithID(t.TemplateID).
|
|
InOrg(t.TemplateOrganizationID).
|
|
WithACLUserList(t.UserACL).
|
|
WithGroupACL(t.GroupACL)
|
|
}
|
|
|
|
// RBACObject for a workspace build's provisioner state requires Update access of the template.
|
|
func (t GetWorkspaceBuildProvisionerStateByIDRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceTemplate.WithID(t.TemplateID).
|
|
InOrg(t.TemplateOrganizationID).
|
|
WithACLUserList(t.UserACL).
|
|
WithGroupACL(t.GroupACL)
|
|
}
|
|
|
|
func (t Template) DeepCopy() Template {
|
|
cpy := t
|
|
cpy.UserACL = maps.Clone(t.UserACL)
|
|
cpy.GroupACL = maps.Clone(t.GroupACL)
|
|
return cpy
|
|
}
|
|
|
|
// AutostartAllowedDays returns the inverse of 'AutostartBlockDaysOfWeek'.
|
|
// It is more useful to have the days that are allowed to autostart from a UX
|
|
// POV. The database prefers the 0 value being 'all days allowed'.
|
|
func (t Template) AutostartAllowedDays() uint8 {
|
|
// Just flip the binary 0s to 1s and vice versa.
|
|
// There is an extra day with the 8th bit that needs to be zeroed.
|
|
// #nosec G115 - Safe conversion for AutostartBlockDaysOfWeek which is 7 bits
|
|
return ^uint8(t.AutostartBlockDaysOfWeek) & 0b01111111
|
|
}
|
|
|
|
func (TemplateVersion) RBACObject(template Template) rbac.Object {
|
|
// Just use the parent template resource for controlling versions
|
|
return template.RBACObject()
|
|
}
|
|
|
|
func (i InboxNotification) RBACObject() rbac.Object {
|
|
return rbac.ResourceInboxNotification.
|
|
WithID(i.ID).
|
|
WithOwner(i.UserID.String())
|
|
}
|
|
|
|
// RBACObjectNoTemplate is for orphaned template versions.
|
|
func (v TemplateVersion) RBACObjectNoTemplate() rbac.Object {
|
|
return rbac.ResourceTemplate.InOrg(v.OrganizationID)
|
|
}
|
|
|
|
func (g Group) RBACObject() rbac.Object {
|
|
return rbac.ResourceGroup.WithID(g.ID).
|
|
InOrg(g.OrganizationID).
|
|
// Group members can read the group.
|
|
WithGroupACL(map[string][]policy.Action{
|
|
g.ID.String(): {
|
|
policy.ActionRead,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (g GetGroupsRow) RBACObject() rbac.Object {
|
|
return g.Group.RBACObject()
|
|
}
|
|
|
|
func (g GetOrganizationGroupsAISpendRow) RBACObject() rbac.Object {
|
|
return Group{ID: g.GroupID, OrganizationID: g.OrganizationID}.RBACObject()
|
|
}
|
|
|
|
func (gm GroupMember) RBACObject() rbac.Object {
|
|
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
|
|
}
|
|
|
|
func (gm GetGroupMembersByGroupIDPaginatedRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
|
|
}
|
|
|
|
func (r GetGroupMembersAISpendRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceGroupMember.WithID(r.UserID).InOrg(r.OrganizationID).WithOwner(r.UserID.String())
|
|
}
|
|
|
|
func (r ExportOrganizationAISpendRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceGroupMember.WithID(r.UserID).InOrg(r.OrganizationID).WithOwner(r.UserID.String())
|
|
}
|
|
|
|
// PrebuiltWorkspaceResource defines the interface for types that can be identified as prebuilt workspaces
|
|
// and converted to their corresponding prebuilt workspace RBAC object.
|
|
type PrebuiltWorkspaceResource interface {
|
|
IsPrebuild() bool
|
|
AsPrebuild() rbac.Object
|
|
}
|
|
|
|
// WorkspaceTable converts a Workspace to it's reduced version.
|
|
// A more generalized solution is to use json marshaling to
|
|
// consistently keep these two structs in sync.
|
|
// That would be a lot of overhead, and a more costly unit test is
|
|
// written to make sure these match up.
|
|
func (w Workspace) WorkspaceTable() WorkspaceTable {
|
|
return WorkspaceTable{
|
|
ID: w.ID,
|
|
CreatedAt: w.CreatedAt,
|
|
UpdatedAt: w.UpdatedAt,
|
|
OwnerID: w.OwnerID,
|
|
OrganizationID: w.OrganizationID,
|
|
TemplateID: w.TemplateID,
|
|
Deleted: w.Deleted,
|
|
Name: w.Name,
|
|
AutostartSchedule: w.AutostartSchedule,
|
|
Ttl: w.Ttl,
|
|
LastUsedAt: w.LastUsedAt,
|
|
DormantAt: w.DormantAt,
|
|
DeletingAt: w.DeletingAt,
|
|
AutomaticUpdates: w.AutomaticUpdates,
|
|
Favorite: w.Favorite,
|
|
NextStartAt: w.NextStartAt,
|
|
GroupACL: w.GroupACL,
|
|
UserACL: w.UserACL,
|
|
}
|
|
}
|
|
|
|
func (w Workspace) RBACObject() rbac.Object {
|
|
return w.WorkspaceTable().RBACObject()
|
|
}
|
|
|
|
// IsPrebuild returns true if the workspace is a prebuild workspace.
|
|
// A workspace is considered a prebuild if its owner is the prebuild system user.
|
|
func (w Workspace) IsPrebuild() bool {
|
|
return w.OwnerID == PrebuildsSystemUserID
|
|
}
|
|
|
|
// AsPrebuild returns the RBAC object corresponding to the workspace type.
|
|
// If the workspace is a prebuild, it returns a prebuilt_workspace RBAC object.
|
|
// Otherwise, it returns a normal workspace RBAC object.
|
|
func (w Workspace) AsPrebuild() rbac.Object {
|
|
if w.IsPrebuild() {
|
|
return rbac.ResourcePrebuiltWorkspace.WithID(w.ID).
|
|
InOrg(w.OrganizationID).
|
|
WithOwner(w.OwnerID.String())
|
|
}
|
|
return w.RBACObject()
|
|
}
|
|
|
|
func (w WorkspaceTable) RBACObject() rbac.Object {
|
|
if w.DormantAt.Valid {
|
|
return w.DormantRBAC()
|
|
}
|
|
|
|
obj := rbac.ResourceWorkspace.
|
|
WithID(w.ID).
|
|
InOrg(w.OrganizationID).
|
|
WithOwner(w.OwnerID.String())
|
|
|
|
if rbac.WorkspaceACLDisabled() {
|
|
return obj
|
|
}
|
|
|
|
return obj.
|
|
WithGroupACL(w.GroupACL.RBACACL()).
|
|
WithACLUserList(w.UserACL.RBACACL())
|
|
}
|
|
|
|
func (w WorkspaceTable) DormantRBAC() rbac.Object {
|
|
return rbac.ResourceWorkspaceDormant.
|
|
WithID(w.ID).
|
|
InOrg(w.OrganizationID).
|
|
WithOwner(w.OwnerID.String())
|
|
}
|
|
|
|
// IsPrebuild returns true if the workspace is a prebuild workspace.
|
|
// A workspace is considered a prebuild if its owner is the prebuild system user.
|
|
func (w WorkspaceTable) IsPrebuild() bool {
|
|
return w.OwnerID == PrebuildsSystemUserID
|
|
}
|
|
|
|
// AsPrebuild returns the RBAC object corresponding to the workspace type.
|
|
// If the workspace is a prebuild, it returns a prebuilt_workspace RBAC object.
|
|
// Otherwise, it returns a normal workspace RBAC object.
|
|
func (w WorkspaceTable) AsPrebuild() rbac.Object {
|
|
if w.IsPrebuild() {
|
|
return rbac.ResourcePrebuiltWorkspace.WithID(w.ID).
|
|
InOrg(w.OrganizationID).
|
|
WithOwner(w.OwnerID.String())
|
|
}
|
|
return w.RBACObject()
|
|
}
|
|
|
|
func (m OrganizationMember) RBACObject() rbac.Object {
|
|
return rbac.ResourceOrganizationMember.
|
|
WithID(m.UserID).
|
|
InOrg(m.OrganizationID).
|
|
WithOwner(m.UserID.String())
|
|
}
|
|
|
|
func (m OrganizationMembersRow) RBACObject() rbac.Object {
|
|
return m.OrganizationMember.RBACObject()
|
|
}
|
|
|
|
func (m PaginatedOrganizationMembersRow) RBACObject() rbac.Object {
|
|
return m.OrganizationMember.RBACObject()
|
|
}
|
|
|
|
func (m GetOrganizationIDsByMemberIDsRow) RBACObject() rbac.Object {
|
|
// TODO: This feels incorrect as we are really returning a list of orgmembers.
|
|
// This return type should be refactored to return a list of orgmembers, not this
|
|
// special type.
|
|
return rbac.ResourceUserObject(m.UserID)
|
|
}
|
|
|
|
func (o Organization) RBACObject() rbac.Object {
|
|
return rbac.ResourceOrganization.
|
|
WithID(o.ID).
|
|
InOrg(o.ID)
|
|
}
|
|
|
|
func (p ProvisionerDaemon) RBACObject() rbac.Object {
|
|
return rbac.ResourceProvisionerDaemon.
|
|
WithID(p.ID).
|
|
InOrg(p.OrganizationID)
|
|
}
|
|
|
|
func (p GetProvisionerDaemonsWithStatusByOrganizationRow) RBACObject() rbac.Object {
|
|
return p.ProvisionerDaemon.RBACObject()
|
|
}
|
|
|
|
func (p GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) RBACObject() rbac.Object {
|
|
return p.ProvisionerDaemon.RBACObject()
|
|
}
|
|
|
|
// RBACObject for a provisioner key is the same as a provisioner daemon.
|
|
// Keys == provisioners from a RBAC perspective.
|
|
func (p ProvisionerKey) RBACObject() rbac.Object {
|
|
return rbac.ResourceProvisionerDaemon.
|
|
WithID(p.ID).
|
|
InOrg(p.OrganizationID)
|
|
}
|
|
|
|
func (w WorkspaceProxy) RBACObject() rbac.Object {
|
|
return rbac.ResourceWorkspaceProxy.
|
|
WithID(w.ID)
|
|
}
|
|
|
|
func (w WorkspaceProxy) IsPrimary() bool {
|
|
return w.Name == "primary"
|
|
}
|
|
|
|
func (f File) RBACObject() rbac.Object {
|
|
return rbac.ResourceFile.
|
|
WithID(f.ID).
|
|
WithOwner(f.CreatedBy.String())
|
|
}
|
|
|
|
// RBACObject returns the RBAC object for the site wide user resource.
|
|
func (u User) RBACObject() rbac.Object {
|
|
return rbac.ResourceUserObject(u.ID)
|
|
}
|
|
|
|
func (u GetUsersRow) RBACObject() rbac.Object {
|
|
return rbac.ResourceUserObject(u.ID)
|
|
}
|
|
|
|
func (u GitSSHKey) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) }
|
|
func (u ExternalAuthLink) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) }
|
|
func (u UserLink) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) }
|
|
func (u MCPServerUserToken) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) }
|
|
|
|
func (u ExternalAuthLink) OAuthToken() *oauth2.Token {
|
|
return &oauth2.Token{
|
|
AccessToken: u.OAuthAccessToken,
|
|
RefreshToken: u.OAuthRefreshToken,
|
|
Expiry: u.OAuthExpiry,
|
|
}
|
|
}
|
|
|
|
func (l License) RBACObject() rbac.Object {
|
|
return rbac.ResourceLicense.WithIDString(strconv.FormatInt(int64(l.ID), 10))
|
|
}
|
|
|
|
func (c OAuth2ProviderAppCode) RBACObject() rbac.Object {
|
|
return rbac.ResourceOauth2AppCodeToken.WithOwner(c.UserID.String())
|
|
}
|
|
|
|
func (t OAuth2ProviderAppToken) RBACObject() rbac.Object {
|
|
return rbac.ResourceOauth2AppCodeToken.WithOwner(t.UserID.String()).WithID(t.ID)
|
|
}
|
|
|
|
func (OAuth2ProviderAppSecret) RBACObject() rbac.Object {
|
|
return rbac.ResourceOauth2AppSecret
|
|
}
|
|
|
|
func (OAuth2ProviderApp) RBACObject() rbac.Object {
|
|
return rbac.ResourceOauth2App
|
|
}
|
|
|
|
// IsPublic reports whether the app is a public (secretless, PKCE-only)
|
|
// OAuth2 client per RFC 7591 §2 / OAuth 2.1 §2.1, as opposed to confidential.
|
|
// An unset or unrecognized client type reads as confidential, so an app can
|
|
// never skip client authentication by accident.
|
|
func (a OAuth2ProviderApp) IsPublic() bool {
|
|
return a.ClientType == OAuth2ProviderAppClientTypePublic
|
|
}
|
|
|
|
// RegisteredRedirectURIs returns the redirect URIs the authorize and token
|
|
// endpoints accept, primary first. RedirectUris is the source of truth.
|
|
// The result is never empty: callers index the first entry directly.
|
|
func (a OAuth2ProviderApp) RegisteredRedirectURIs() []string {
|
|
if len(a.RedirectUris) == 0 {
|
|
return []string{a.CallbackURL}
|
|
}
|
|
return slice.Unique(a.RedirectUris)
|
|
}
|
|
|
|
func (a GetOAuth2ProviderAppsByUserIDRow) RBACObject() rbac.Object {
|
|
return a.OAuth2ProviderApp.RBACObject()
|
|
}
|
|
|
|
type WorkspaceAgentConnectionStatus struct {
|
|
Status WorkspaceAgentStatus `json:"status"`
|
|
FirstConnectedAt *time.Time `json:"first_connected_at"`
|
|
LastConnectedAt *time.Time `json:"last_connected_at"`
|
|
DisconnectedAt *time.Time `json:"disconnected_at"`
|
|
}
|
|
|
|
func (a WorkspaceAgent) Status(now time.Time, inactiveTimeout time.Duration) WorkspaceAgentConnectionStatus {
|
|
connectionTimeout := time.Duration(a.ConnectionTimeoutSeconds) * time.Second
|
|
|
|
status := WorkspaceAgentConnectionStatus{
|
|
Status: WorkspaceAgentStatusDisconnected,
|
|
}
|
|
if a.FirstConnectedAt.Valid {
|
|
status.FirstConnectedAt = &a.FirstConnectedAt.Time
|
|
}
|
|
if a.LastConnectedAt.Valid {
|
|
status.LastConnectedAt = &a.LastConnectedAt.Time
|
|
}
|
|
if a.DisconnectedAt.Valid {
|
|
status.DisconnectedAt = &a.DisconnectedAt.Time
|
|
}
|
|
|
|
switch {
|
|
case !a.FirstConnectedAt.Valid:
|
|
switch {
|
|
case connectionTimeout > 0 && now.Sub(a.CreatedAt) > connectionTimeout:
|
|
// If the agent took too long to connect the first time,
|
|
// mark it as timed out.
|
|
status.Status = WorkspaceAgentStatusTimeout
|
|
default:
|
|
// If the agent never connected, it's waiting for the compute
|
|
// to start up.
|
|
status.Status = WorkspaceAgentStatusConnecting
|
|
}
|
|
// We check before instead of after because last connected at and
|
|
// disconnected at can be equal timestamps in tight-timed tests.
|
|
case !a.DisconnectedAt.Time.Before(a.LastConnectedAt.Time):
|
|
// If we've disconnected after our last connection, we know the
|
|
// agent is no longer connected.
|
|
status.Status = WorkspaceAgentStatusDisconnected
|
|
case now.Sub(a.LastConnectedAt.Time) > inactiveTimeout:
|
|
// The connection died without updating the last connected.
|
|
status.Status = WorkspaceAgentStatusDisconnected
|
|
// Client code needs an accurate disconnected at if the agent has been inactive.
|
|
status.DisconnectedAt = &a.LastConnectedAt.Time
|
|
case a.LastConnectedAt.Valid:
|
|
// The agent should be assumed connected if it's under inactivity timeouts
|
|
// and last connected at has been properly set.
|
|
status.Status = WorkspaceAgentStatusConnected
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
func ConvertUserRows(rows []GetUsersRow) []User {
|
|
users := make([]User, len(rows))
|
|
for i, r := range rows {
|
|
users[i] = User{
|
|
ID: r.ID,
|
|
Email: r.Email,
|
|
Username: r.Username,
|
|
Name: r.Name,
|
|
HashedPassword: r.HashedPassword,
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
Status: r.Status,
|
|
RBACRoles: r.RBACRoles,
|
|
LoginType: r.LoginType,
|
|
AvatarURL: r.AvatarURL,
|
|
Deleted: r.Deleted,
|
|
LastSeenAt: r.LastSeenAt,
|
|
IsSystem: r.IsSystem,
|
|
IsServiceAccount: r.IsServiceAccount,
|
|
}
|
|
}
|
|
|
|
return users
|
|
}
|
|
|
|
func ConvertWorkspaceRows(rows []GetWorkspacesRow) ([]Workspace, error) {
|
|
workspaces := make([]Workspace, len(rows))
|
|
for i, r := range rows {
|
|
workspaces[i] = Workspace{
|
|
ID: r.ID,
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
OwnerID: r.OwnerID,
|
|
OrganizationID: r.OrganizationID,
|
|
TemplateID: r.TemplateID,
|
|
Deleted: r.Deleted,
|
|
Name: r.Name,
|
|
AutostartSchedule: r.AutostartSchedule,
|
|
Ttl: r.Ttl,
|
|
LastUsedAt: r.LastUsedAt,
|
|
DormantAt: r.DormantAt,
|
|
DeletingAt: r.DeletingAt,
|
|
AutomaticUpdates: r.AutomaticUpdates,
|
|
Favorite: r.Favorite,
|
|
OwnerAvatarUrl: r.OwnerAvatarUrl,
|
|
OwnerUsername: r.OwnerUsername,
|
|
OwnerName: r.OwnerName,
|
|
OrganizationName: r.OrganizationName,
|
|
OrganizationDisplayName: r.OrganizationDisplayName,
|
|
OrganizationIcon: r.OrganizationIcon,
|
|
OrganizationDescription: r.OrganizationDescription,
|
|
TemplateName: r.TemplateName,
|
|
TemplateDisplayName: r.TemplateDisplayName,
|
|
TemplateIcon: r.TemplateIcon,
|
|
TemplateDescription: r.TemplateDescription,
|
|
NextStartAt: r.NextStartAt,
|
|
}
|
|
|
|
var err error
|
|
|
|
err = workspaces[i].UserACL.Scan(r.UserACL)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("scan user ACL %q: %w", r.UserACL, err)
|
|
}
|
|
err = workspaces[i].GroupACL.Scan(r.GroupACL)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("scan group ACL %q: %w", r.GroupACL, err)
|
|
}
|
|
|
|
err = workspaces[i].UserACLDisplayInfo.Scan(r.UserACLDisplayInfo)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("scan user ACL display info %q: %w",
|
|
r.UserACLDisplayInfo, err)
|
|
}
|
|
err = workspaces[i].GroupACLDisplayInfo.Scan(r.GroupACLDisplayInfo)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("scan group ACL display info %q: %w",
|
|
r.GroupACLDisplayInfo, err)
|
|
}
|
|
}
|
|
|
|
return workspaces, nil
|
|
}
|
|
|
|
func (g Group) IsEveryone() bool {
|
|
return g.ID == g.OrganizationID
|
|
}
|
|
|
|
func (p ProvisionerJob) RBACObject() rbac.Object {
|
|
switch p.Type {
|
|
// Only acceptable for known job types at this time because template
|
|
// admins may not be allowed to view new types.
|
|
case ProvisionerJobTypeTemplateVersionImport, ProvisionerJobTypeTemplateVersionDryRun, ProvisionerJobTypeWorkspaceBuild:
|
|
return rbac.ResourceProvisionerJobs.InOrg(p.OrganizationID)
|
|
|
|
default:
|
|
panic("developer error: unknown provisioner job type " + string(p.Type))
|
|
}
|
|
}
|
|
|
|
func (p ProvisionerJob) Finished() bool {
|
|
return p.CanceledAt.Valid || p.CompletedAt.Valid
|
|
}
|
|
|
|
func (p ProvisionerJob) FinishedAt() time.Time {
|
|
if p.CompletedAt.Valid {
|
|
return p.CompletedAt.Time
|
|
}
|
|
|
|
if p.CanceledAt.Valid {
|
|
return p.CanceledAt.Time
|
|
}
|
|
|
|
return time.Time{}
|
|
}
|
|
|
|
func (r CustomRole) RoleIdentifier() rbac.RoleIdentifier {
|
|
return rbac.RoleIdentifier{
|
|
Name: r.Name,
|
|
OrganizationID: r.OrganizationID.UUID,
|
|
}
|
|
}
|
|
|
|
func (r GetAuthorizationUserRolesRow) RoleNames() ([]rbac.RoleIdentifier, error) {
|
|
names := make([]rbac.RoleIdentifier, 0, len(r.Roles))
|
|
for _, role := range r.Roles {
|
|
value, err := rbac.RoleNameFromString(role)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("convert role %q: %w", role, err)
|
|
}
|
|
names = append(names, value)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func (r GetActiveUsersAuthorizationRolesRow) RoleNames() ([]rbac.RoleIdentifier, error) {
|
|
names := make([]rbac.RoleIdentifier, 0, len(r.Roles))
|
|
for _, role := range r.Roles {
|
|
value, err := rbac.RoleNameFromString(role)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("convert role %q: %w", role, err)
|
|
}
|
|
names = append(names, value)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func (k CryptoKey) ExpiresAt(keyDuration time.Duration) time.Time {
|
|
return k.StartsAt.Add(keyDuration).UTC()
|
|
}
|
|
|
|
func (k CryptoKey) CanSign(now time.Time) bool {
|
|
isAfterStart := !k.StartsAt.IsZero() && !now.Before(k.StartsAt)
|
|
return isAfterStart && k.CanVerify(now)
|
|
}
|
|
|
|
func (k CryptoKey) CanVerify(now time.Time) bool {
|
|
hasSecret := k.Secret.Valid
|
|
isBeforeDeletion := !k.DeletesAt.Valid || now.Before(k.DeletesAt.Time)
|
|
return hasSecret && isBeforeDeletion
|
|
}
|
|
|
|
func (r GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) RBACObject() rbac.Object {
|
|
return r.ProvisionerJob.RBACObject()
|
|
}
|
|
|
|
func (m WorkspaceAgentMemoryResourceMonitor) Debounce(
|
|
by time.Duration,
|
|
now time.Time,
|
|
oldState, newState WorkspaceAgentMonitorState,
|
|
) (time.Time, bool) {
|
|
if now.After(m.DebouncedUntil) &&
|
|
oldState == WorkspaceAgentMonitorStateOK &&
|
|
newState == WorkspaceAgentMonitorStateNOK {
|
|
return now.Add(by), true
|
|
}
|
|
|
|
return m.DebouncedUntil, false
|
|
}
|
|
|
|
func (m WorkspaceAgentVolumeResourceMonitor) Debounce(
|
|
by time.Duration,
|
|
now time.Time,
|
|
oldState, newState WorkspaceAgentMonitorState,
|
|
) (debouncedUntil time.Time, shouldNotify bool) {
|
|
if now.After(m.DebouncedUntil) &&
|
|
oldState == WorkspaceAgentMonitorStateOK &&
|
|
newState == WorkspaceAgentMonitorStateNOK {
|
|
return now.Add(by), true
|
|
}
|
|
|
|
return m.DebouncedUntil, false
|
|
}
|
|
|
|
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 {
|
|
return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String())
|
|
}
|
|
|
|
func (s AIBridgeInterception) RBACObject() rbac.Object {
|
|
return rbac.ResourceAibridgeInterception.WithOwner(s.InitiatorID.String())
|
|
}
|
|
|
|
// WorkspaceIdentity contains the minimal workspace fields needed for agent API metadata/stats reporting
|
|
// and RBAC checks, without requiring a full database.Workspace object.
|
|
type WorkspaceIdentity struct {
|
|
// Add any other fields needed for IsPrebuild() if it relies on workspace fields
|
|
// Identity fields
|
|
ID uuid.UUID
|
|
OwnerID uuid.UUID
|
|
OrganizationID uuid.UUID
|
|
TemplateID uuid.UUID
|
|
|
|
// Display fields for logging/metrics
|
|
Name string
|
|
OwnerUsername string
|
|
TemplateName string
|
|
|
|
// Lifecycle fields needed for stats reporting
|
|
AutostartSchedule sql.NullString
|
|
}
|
|
|
|
func (w WorkspaceIdentity) RBACObject() rbac.Object {
|
|
return Workspace{
|
|
ID: w.ID,
|
|
OwnerID: w.OwnerID,
|
|
OrganizationID: w.OrganizationID,
|
|
TemplateID: w.TemplateID,
|
|
Name: w.Name,
|
|
OwnerUsername: w.OwnerUsername,
|
|
TemplateName: w.TemplateName,
|
|
AutostartSchedule: w.AutostartSchedule,
|
|
}.RBACObject()
|
|
}
|
|
|
|
// IsPrebuild returns true if the workspace is a prebuild workspace.
|
|
// A workspace is considered a prebuild if its owner is the prebuild system user.
|
|
func (w WorkspaceIdentity) IsPrebuild() bool {
|
|
return w.OwnerID == PrebuildsSystemUserID
|
|
}
|
|
|
|
func (w WorkspaceIdentity) Equal(w2 WorkspaceIdentity) bool {
|
|
return w.ID == w2.ID && w.OwnerID == w2.OwnerID && w.OrganizationID == w2.OrganizationID &&
|
|
w.TemplateID == w2.TemplateID && w.Name == w2.Name && w.OwnerUsername == w2.OwnerUsername &&
|
|
w.TemplateName == w2.TemplateName && w.AutostartSchedule == w2.AutostartSchedule
|
|
}
|
|
|
|
func WorkspaceIdentityFromWorkspace(w Workspace) WorkspaceIdentity {
|
|
return WorkspaceIdentity{
|
|
ID: w.ID,
|
|
OwnerID: w.OwnerID,
|
|
OrganizationID: w.OrganizationID,
|
|
TemplateID: w.TemplateID,
|
|
Name: w.Name,
|
|
OwnerUsername: w.OwnerUsername,
|
|
TemplateName: w.TemplateName,
|
|
AutostartSchedule: w.AutostartSchedule,
|
|
}
|
|
}
|
|
|
|
// A workspace agent belongs to the owner of the associated workspace.
|
|
func (r GetWorkspaceAgentAndWorkspaceByIDRow) RBACObject() rbac.Object {
|
|
return r.WorkspaceTable.RBACObject()
|
|
}
|
|
|
|
// A workspace agent belongs to the owner of the associated workspace.
|
|
func (r GetWorkspaceBuildAgentsByInstanceIDRow) RBACObject() rbac.Object {
|
|
return r.WorkspaceTable.RBACObject()
|
|
}
|
|
|
|
// UpsertConnectionLogParams contains the parameters for upserting a
|
|
// connection log entry. This struct is hand-maintained (not generated
|
|
// by sqlc) because the single-row UpsertConnectionLog query was
|
|
// removed in favor of BatchUpsertConnectionLogs, but the struct is
|
|
// still used as the canonical connection log event type throughout
|
|
// the codebase.
|
|
type UpsertConnectionLogParams struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
|
WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"`
|
|
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
|
WorkspaceName string `db:"workspace_name" json:"workspace_name"`
|
|
AgentName string `db:"agent_name" json:"agent_name"`
|
|
ConnectionMethod ConnectionLogMethod `db:"connection_method" json:"connection_method"`
|
|
Code sql.NullInt32 `db:"code" json:"code"`
|
|
IP pqtype.Inet `db:"ip" json:"ip"`
|
|
UserAgent sql.NullString `db:"user_agent" json:"user_agent"`
|
|
UserID uuid.NullUUID `db:"user_id" json:"user_id"`
|
|
AppNameOrPort sql.NullString `db:"app_name_or_port" json:"app_name_or_port"`
|
|
ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"`
|
|
DisconnectReason sql.NullString `db:"disconnect_reason" json:"disconnect_reason"`
|
|
ClientSessionID sql.NullString `db:"client_session_id" json:"client_session_id"`
|
|
Time time.Time `db:"time" json:"time"`
|
|
ConnectionStatus ConnectionStatus `db:"connection_status" json:"connection_status"`
|
|
}
|
|
|
|
func (r GetLatestWorkspaceBuildWithStatusByWorkspaceIDRow) RBACObject() rbac.Object {
|
|
return r.WorkspaceTable.RBACObject()
|
|
}
|