mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
Merge branch 'main' of github.com:/coder/coder into dk/prebuilds
This commit is contained in:
@ -19,6 +19,7 @@ import (
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentapi/resourcesmonitor"
|
||||
"github.com/coder/coder/v2/coderd/appearance"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
@ -48,6 +49,7 @@ type API struct {
|
||||
*ResourcesMonitoringAPI
|
||||
*LogsAPI
|
||||
*ScriptsAPI
|
||||
*AuditAPI
|
||||
*tailnet.DRPCService
|
||||
|
||||
mu sync.Mutex
|
||||
@ -66,6 +68,7 @@ type Options struct {
|
||||
Database database.Store
|
||||
NotificationsEnqueuer notifications.Enqueuer
|
||||
Pubsub pubsub.Pubsub
|
||||
Auditor *atomic.Pointer[audit.Auditor]
|
||||
DerpMapFn func() *tailcfg.DERPMap
|
||||
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
|
||||
StatsReporter *workspacestats.Reporter
|
||||
@ -176,6 +179,13 @@ func New(opts Options) *API {
|
||||
Database: opts.Database,
|
||||
}
|
||||
|
||||
api.AuditAPI = &AuditAPI{
|
||||
AgentFn: api.agent,
|
||||
Auditor: opts.Auditor,
|
||||
Database: opts.Database,
|
||||
Log: opts.Log,
|
||||
}
|
||||
|
||||
api.DRPCService = &tailnet.DRPCService{
|
||||
CoordPtr: opts.TailnetCoordinator,
|
||||
Logger: opts.Log,
|
||||
|
105
coderd/agentapi/audit.go
Normal file
105
coderd/agentapi/audit.go
Normal file
@ -0,0 +1,105 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
type AuditAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Auditor *atomic.Pointer[audit.Auditor]
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
}
|
||||
|
||||
func (a *AuditAPI) ReportConnection(ctx context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) {
|
||||
// We will use connection ID as request ID, typically this is the
|
||||
// SSH session ID as reported by the agent.
|
||||
connectionID, err := uuid.FromBytes(req.GetConnection().GetId())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("connection id from bytes: %w", err)
|
||||
}
|
||||
|
||||
action, err := db2sdk.AuditActionFromAgentProtoConnectionAction(req.GetConnection().GetAction())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connectionType, err := agentsdk.ConnectionTypeFromProto(req.GetConnection().GetType())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch contextual data for this audit event.
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get agent: %w", err)
|
||||
}
|
||||
workspace, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace by agent id: %w", err)
|
||||
}
|
||||
build, err := a.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get latest workspace build by workspace id: %w", err)
|
||||
}
|
||||
|
||||
// We pass the below information to the Auditor so that it
|
||||
// can form a friendly string for the user to view in the UI.
|
||||
type additionalFields struct {
|
||||
audit.AdditionalFields
|
||||
|
||||
ConnectionType agentsdk.ConnectionType `json:"connection_type"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
resourceInfo := additionalFields{
|
||||
AdditionalFields: audit.AdditionalFields{
|
||||
WorkspaceID: workspace.ID,
|
||||
WorkspaceName: workspace.Name,
|
||||
WorkspaceOwner: workspace.OwnerUsername,
|
||||
BuildNumber: strconv.FormatInt(int64(build.BuildNumber), 10),
|
||||
BuildReason: database.BuildReason(string(build.Reason)),
|
||||
},
|
||||
ConnectionType: connectionType,
|
||||
Reason: req.GetConnection().GetReason(),
|
||||
}
|
||||
|
||||
riBytes, err := json.Marshal(resourceInfo)
|
||||
if err != nil {
|
||||
a.Log.Error(ctx, "marshal resource info for agent connection failed", slog.Error(err))
|
||||
riBytes = []byte("{}")
|
||||
}
|
||||
|
||||
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceAgent]{
|
||||
Audit: *a.Auditor.Load(),
|
||||
Log: a.Log,
|
||||
Time: req.GetConnection().GetTimestamp().AsTime(),
|
||||
OrganizationID: workspace.OrganizationID,
|
||||
RequestID: connectionID,
|
||||
Action: action,
|
||||
New: workspaceAgent,
|
||||
Old: workspaceAgent,
|
||||
IP: req.GetConnection().GetIp(),
|
||||
Status: int(req.GetConnection().GetStatusCode()),
|
||||
AdditionalFields: riBytes,
|
||||
|
||||
// It's not possible to tell which user connected. Once we have
|
||||
// the capability, this may be reported by the agent.
|
||||
UserID: uuid.Nil,
|
||||
})
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
179
coderd/agentapi/audit_test.go
Normal file
179
coderd/agentapi/audit_test.go
Normal file
@ -0,0 +1,179 @@
|
||||
package agentapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentapi"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
func TestAuditReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
owner = database.User{
|
||||
ID: uuid.New(),
|
||||
Username: "cool-user",
|
||||
}
|
||||
workspace = database.Workspace{
|
||||
ID: uuid.New(),
|
||||
OrganizationID: uuid.New(),
|
||||
OwnerID: owner.ID,
|
||||
Name: "cool-workspace",
|
||||
}
|
||||
build = database.WorkspaceBuild{
|
||||
ID: uuid.New(),
|
||||
WorkspaceID: workspace.ID,
|
||||
}
|
||||
agent = database.WorkspaceAgent{
|
||||
ID: uuid.New(),
|
||||
}
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id uuid.UUID
|
||||
action *agentproto.Connection_Action
|
||||
typ *agentproto.Connection_Type
|
||||
time time.Time
|
||||
ip string
|
||||
status int32
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "SSH Connect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_CONNECT.Enum(),
|
||||
typ: agentproto.Connection_SSH.Enum(),
|
||||
time: time.Now(),
|
||||
ip: "127.0.0.1",
|
||||
status: 200,
|
||||
},
|
||||
{
|
||||
name: "VS Code Connect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_CONNECT.Enum(),
|
||||
typ: agentproto.Connection_VSCODE.Enum(),
|
||||
time: time.Now(),
|
||||
ip: "8.8.8.8",
|
||||
},
|
||||
{
|
||||
name: "JetBrains Connect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_CONNECT.Enum(),
|
||||
typ: agentproto.Connection_JETBRAINS.Enum(),
|
||||
time: time.Now(),
|
||||
},
|
||||
{
|
||||
name: "Reconnecting PTY Connect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_CONNECT.Enum(),
|
||||
typ: agentproto.Connection_RECONNECTING_PTY.Enum(),
|
||||
time: time.Now(),
|
||||
},
|
||||
{
|
||||
name: "SSH Disconnect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_DISCONNECT.Enum(),
|
||||
typ: agentproto.Connection_SSH.Enum(),
|
||||
time: time.Now(),
|
||||
},
|
||||
{
|
||||
name: "SSH Disconnect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_DISCONNECT.Enum(),
|
||||
typ: agentproto.Connection_SSH.Enum(),
|
||||
time: time.Now(),
|
||||
status: 500,
|
||||
reason: "because error says so",
|
||||
},
|
||||
}
|
||||
//nolint:paralleltest // No longer necessary to reinitialise the variable tt.
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mAudit := audit.NewMock()
|
||||
|
||||
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
||||
mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||
mDB.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspace.ID).Return(build, nil)
|
||||
|
||||
api := &agentapi.AuditAPI{
|
||||
Auditor: asAtomicPointer[audit.Auditor](mAudit),
|
||||
Database: mDB,
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
}
|
||||
api.ReportConnection(context.Background(), &agentproto.ReportConnectionRequest{
|
||||
Connection: &agentproto.Connection{
|
||||
Id: tt.id[:],
|
||||
Action: *tt.action,
|
||||
Type: *tt.typ,
|
||||
Timestamp: timestamppb.New(tt.time),
|
||||
Ip: tt.ip,
|
||||
StatusCode: tt.status,
|
||||
Reason: &tt.reason,
|
||||
},
|
||||
})
|
||||
|
||||
mAudit.Contains(t, database.AuditLog{
|
||||
Time: dbtime.Time(tt.time).In(time.UTC),
|
||||
Action: agentProtoConnectionActionToAudit(t, *tt.action),
|
||||
OrganizationID: workspace.OrganizationID,
|
||||
UserID: uuid.Nil,
|
||||
RequestID: tt.id,
|
||||
ResourceType: database.ResourceTypeWorkspaceAgent,
|
||||
ResourceID: agent.ID,
|
||||
ResourceTarget: agent.Name,
|
||||
Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}},
|
||||
StatusCode: tt.status,
|
||||
})
|
||||
|
||||
// Check some additional fields.
|
||||
var m map[string]any
|
||||
err := json.Unmarshal(mAudit.AuditLogs()[0].AdditionalFields, &m)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(agentProtoConnectionTypeToSDK(t, *tt.typ)), m["connection_type"].(string))
|
||||
if tt.reason != "" {
|
||||
require.Equal(t, tt.reason, m["reason"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func agentProtoConnectionActionToAudit(t *testing.T, action agentproto.Connection_Action) database.AuditAction {
|
||||
a, err := db2sdk.AuditActionFromAgentProtoConnectionAction(action)
|
||||
require.NoError(t, err)
|
||||
return a
|
||||
}
|
||||
|
||||
func agentProtoConnectionTypeToSDK(t *testing.T, typ agentproto.Connection_Type) agentsdk.ConnectionType {
|
||||
action, err := agentsdk.ConnectionTypeFromProto(typ)
|
||||
require.NoError(t, err)
|
||||
return action
|
||||
}
|
||||
|
||||
func asAtomicPointer[T any](v T) *atomic.Pointer[T] {
|
||||
var p atomic.Pointer[T]
|
||||
p.Store(&v)
|
||||
return &p
|
||||
}
|
@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
@ -65,6 +66,7 @@ type BackgroundAuditParams[T Auditable] struct {
|
||||
|
||||
UserID uuid.UUID
|
||||
RequestID uuid.UUID
|
||||
Time time.Time
|
||||
Status int
|
||||
Action database.AuditAction
|
||||
OrganizationID uuid.UUID
|
||||
@ -461,13 +463,19 @@ func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[
|
||||
diffRaw = []byte("{}")
|
||||
}
|
||||
|
||||
if p.Time.IsZero() {
|
||||
p.Time = dbtime.Now()
|
||||
} else {
|
||||
// NOTE(mafredri): dbtime.Time does not currently enforce UTC.
|
||||
p.Time = dbtime.Time(p.Time.In(time.UTC))
|
||||
}
|
||||
if p.AdditionalFields == nil {
|
||||
p.AdditionalFields = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
auditLog := database.AuditLog{
|
||||
ID: uuid.New(),
|
||||
Time: dbtime.Now(),
|
||||
Time: p.Time,
|
||||
UserID: p.UserID,
|
||||
OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log),
|
||||
Ip: ip,
|
||||
|
@ -933,6 +933,25 @@ func New(options *Options) *API {
|
||||
r.Route("/audit", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
// This middleware only checks the site and orgs for the audit_log read
|
||||
// permission.
|
||||
// In the future if it makes sense to have this permission on the user as
|
||||
// well we will need to update this middleware to include that check.
|
||||
func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog) {
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
|
||||
if api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.AnyOrganization()) {
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Forbidden(rw)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
r.Get("/", api.auditLogs)
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
@ -705,3 +706,26 @@ func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action {
|
||||
}
|
||||
return []policy.Action{}
|
||||
}
|
||||
|
||||
func AuditActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.AuditAction, error) {
|
||||
switch action {
|
||||
case agentproto.Connection_CONNECT:
|
||||
return database.AuditActionConnect, nil
|
||||
case agentproto.Connection_DISCONNECT:
|
||||
return database.AuditActionDisconnect, nil
|
||||
default:
|
||||
// Also Connection_ACTION_UNSPECIFIED, no mapping.
|
||||
return "", xerrors.Errorf("unknown agent connection action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agentproto.Connection_Action, error) {
|
||||
switch action {
|
||||
case database.AuditActionConnect:
|
||||
return agentproto.Connection_CONNECT, nil
|
||||
case database.AuditActionDisconnect:
|
||||
return agentproto.Connection_DISCONNECT, nil
|
||||
default:
|
||||
return agentproto.Connection_ACTION_UNSPECIFIED, xerrors.Errorf("unknown agent connection action %q", action)
|
||||
}
|
||||
}
|
||||
|
@ -3918,7 +3918,8 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
s.Run("InsertWorkspaceAgent", s.Subtest(func(db database.Store, check *expects) {
|
||||
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
|
||||
check.Args(database.InsertWorkspaceAgentParams{
|
||||
ID: uuid.New(),
|
||||
ID: uuid.New(),
|
||||
Name: "dev",
|
||||
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
|
||||
}))
|
||||
s.Run("InsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) {
|
||||
|
@ -91,7 +91,8 @@ func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) []
|
||||
//nolint: revive // returns modified struct
|
||||
b.agentToken = uuid.NewString()
|
||||
agents := []*sdkproto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &sdkproto.Agent_Token{
|
||||
Token: b.agentToken,
|
||||
},
|
||||
|
@ -17,58 +17,128 @@ import (
|
||||
func TestTemplateVersionPresets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
givenPreset := codersdk.Preset{
|
||||
Name: "My Preset",
|
||||
Parameters: []codersdk.PresetParameter{
|
||||
{
|
||||
Name: "preset_param1",
|
||||
Value: "A1B2C3",
|
||||
testCases := []struct {
|
||||
name string
|
||||
presets []codersdk.Preset
|
||||
}{
|
||||
{
|
||||
name: "no presets",
|
||||
presets: []codersdk.Preset{},
|
||||
},
|
||||
{
|
||||
name: "single preset with parameters",
|
||||
presets: []codersdk.Preset{
|
||||
{
|
||||
Name: "My Preset",
|
||||
Parameters: []codersdk.PresetParameter{
|
||||
{
|
||||
Name: "preset_param1",
|
||||
Value: "A1B2C3",
|
||||
},
|
||||
{
|
||||
Name: "preset_param2",
|
||||
Value: "D4E5F6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "preset_param2",
|
||||
Value: "D4E5F6",
|
||||
},
|
||||
{
|
||||
name: "multiple presets with overlapping parameters",
|
||||
presets: []codersdk.Preset{
|
||||
{
|
||||
Name: "Preset 1",
|
||||
Parameters: []codersdk.PresetParameter{
|
||||
{
|
||||
Name: "shared_param",
|
||||
Value: "value1",
|
||||
},
|
||||
{
|
||||
Name: "unique_param1",
|
||||
Value: "unique1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Preset 2",
|
||||
Parameters: []codersdk.PresetParameter{
|
||||
{
|
||||
Name: "shared_param",
|
||||
Value: "value2",
|
||||
},
|
||||
{
|
||||
Name: "unique_param2",
|
||||
Value: "unique2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// nolint:gocritic // This is a test
|
||||
provisionerCtx := dbauthz.AsProvisionerd(ctx)
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
|
||||
dbPreset, err := db.InsertPreset(provisionerCtx, database.InsertPresetParams{
|
||||
Name: givenPreset.Name,
|
||||
TemplateVersionID: version.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// nolint:gocritic // This is a test
|
||||
provisionerCtx := dbauthz.AsProvisionerd(ctx)
|
||||
|
||||
var presetParameterNames []string
|
||||
var presetParameterValues []string
|
||||
for _, presetParameter := range givenPreset.Parameters {
|
||||
presetParameterNames = append(presetParameterNames, presetParameter.Name)
|
||||
presetParameterValues = append(presetParameterValues, presetParameter.Value)
|
||||
}
|
||||
_, err = db.InsertPresetParameters(provisionerCtx, database.InsertPresetParametersParams{
|
||||
TemplateVersionPresetID: dbPreset.ID,
|
||||
Names: presetParameterNames,
|
||||
Values: presetParameterValues,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Insert all presets for this test case
|
||||
for _, givenPreset := range tc.presets {
|
||||
dbPreset, err := db.InsertPreset(provisionerCtx, database.InsertPresetParams{
|
||||
Name: givenPreset.Name,
|
||||
TemplateVersionID: version.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, rbac.ScopeAll)
|
||||
require.NoError(t, err)
|
||||
userCtx := dbauthz.As(ctx, userSubject)
|
||||
if len(givenPreset.Parameters) > 0 {
|
||||
var presetParameterNames []string
|
||||
var presetParameterValues []string
|
||||
for _, presetParameter := range givenPreset.Parameters {
|
||||
presetParameterNames = append(presetParameterNames, presetParameter.Name)
|
||||
presetParameterValues = append(presetParameterValues, presetParameter.Value)
|
||||
}
|
||||
_, err = db.InsertPresetParameters(provisionerCtx, database.InsertPresetParametersParams{
|
||||
TemplateVersionPresetID: dbPreset.ID,
|
||||
Names: presetParameterNames,
|
||||
Values: presetParameterValues,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
gotPresets, err := client.TemplateVersionPresets(userCtx, version.ID)
|
||||
require.NoError(t, err)
|
||||
userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, rbac.ScopeAll)
|
||||
require.NoError(t, err)
|
||||
userCtx := dbauthz.As(ctx, userSubject)
|
||||
|
||||
require.Equal(t, 1, len(gotPresets))
|
||||
require.Equal(t, givenPreset.Name, gotPresets[0].Name)
|
||||
gotPresets, err := client.TemplateVersionPresets(userCtx, version.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, presetParameter := range givenPreset.Parameters {
|
||||
require.Contains(t, gotPresets[0].Parameters, presetParameter)
|
||||
require.Equal(t, len(tc.presets), len(gotPresets))
|
||||
|
||||
for _, expectedPreset := range tc.presets {
|
||||
found := false
|
||||
for _, gotPreset := range gotPresets {
|
||||
if gotPreset.Name == expectedPreset.Name {
|
||||
found = true
|
||||
|
||||
// verify not only that we get the right number of parameters, but that we get the right parameters
|
||||
// This ensures that we don't get extra parameters from other presets
|
||||
require.Equal(t, len(expectedPreset.Parameters), len(gotPreset.Parameters))
|
||||
for _, expectedParam := range expectedPreset.Parameters {
|
||||
require.Contains(t, gotPreset.Parameters, expectedParam)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Expected preset %s not found in results", expectedPreset.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -1932,10 +1932,25 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
appSlugs = make(map[string]struct{})
|
||||
)
|
||||
for _, prAgent := range protoResource.Agents {
|
||||
if _, ok := agentNames[prAgent.Name]; ok {
|
||||
// Similar logic is duplicated in terraform/resources.go.
|
||||
if prAgent.Name == "" {
|
||||
return xerrors.Errorf("agent name cannot be empty")
|
||||
}
|
||||
// In 2025-02 we removed support for underscores in agent names. To
|
||||
// provide a nicer error message, we check the regex first and check
|
||||
// for underscores if it fails.
|
||||
if !provisioner.AgentNameRegex.MatchString(prAgent.Name) {
|
||||
if strings.Contains(prAgent.Name, "_") {
|
||||
return xerrors.Errorf("agent name %q contains underscores which are no longer supported, please use hyphens instead (regex: %q)", prAgent.Name, provisioner.AgentNameRegex.String())
|
||||
}
|
||||
return xerrors.Errorf("agent name %q does not match regex %q", prAgent.Name, provisioner.AgentNameRegex.String())
|
||||
}
|
||||
// Agent names must be case-insensitive-unique, to be unambiguous in
|
||||
// `coder_app`s and CoderVPN DNS names.
|
||||
if _, ok := agentNames[strings.ToLower(prAgent.Name)]; ok {
|
||||
return xerrors.Errorf("duplicate agent name %q", prAgent.Name)
|
||||
}
|
||||
agentNames[prAgent.Name] = struct{}{}
|
||||
agentNames[strings.ToLower(prAgent.Name)] = struct{}{}
|
||||
|
||||
var instanceID sql.NullString
|
||||
if prAgent.GetInstanceId() != "" {
|
||||
@ -2109,10 +2124,13 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
}
|
||||
|
||||
for _, app := range prAgent.Apps {
|
||||
// Similar logic is duplicated in terraform/resources.go.
|
||||
slug := app.Slug
|
||||
if slug == "" {
|
||||
return xerrors.Errorf("app must have a slug or name set")
|
||||
}
|
||||
// Contrary to agent names above, app slugs were never permitted to
|
||||
// contain uppercase letters or underscores.
|
||||
if !provisioner.AppSlugRegex.MatchString(slug) {
|
||||
return xerrors.Errorf("app slug %q does not match regex %q", slug, provisioner.AppSlugRegex.String())
|
||||
}
|
||||
|
@ -1883,6 +1883,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
Auth: &sdkproto.Agent_Token{
|
||||
Token: "bananas",
|
||||
},
|
||||
@ -1896,6 +1897,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
Apps: []*sdkproto.App{{
|
||||
Slug: "a",
|
||||
}, {
|
||||
@ -1903,7 +1905,116 @@ func TestInsertWorkspaceResource(t *testing.T) {
|
||||
}},
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, "duplicate app slug")
|
||||
require.ErrorContains(t, err, `duplicate app slug, must be unique per template: "a"`)
|
||||
err = insert(dbmem.New(), uuid.New(), &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev1",
|
||||
Apps: []*sdkproto.App{{
|
||||
Slug: "a",
|
||||
}},
|
||||
}, {
|
||||
Name: "dev2",
|
||||
Apps: []*sdkproto.App{{
|
||||
Slug: "a",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, `duplicate app slug, must be unique per template: "a"`)
|
||||
})
|
||||
t.Run("AppSlugInvalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := dbmem.New()
|
||||
job := uuid.New()
|
||||
err := insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
Apps: []*sdkproto.App{{
|
||||
Slug: "dev_1",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, `app slug "dev_1" does not match regex`)
|
||||
err = insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
Apps: []*sdkproto.App{{
|
||||
Slug: "dev--1",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, `app slug "dev--1" does not match regex`)
|
||||
err = insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
Apps: []*sdkproto.App{{
|
||||
Slug: "Dev",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, `app slug "Dev" does not match regex`)
|
||||
})
|
||||
t.Run("DuplicateAgentNames", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := dbmem.New()
|
||||
job := uuid.New()
|
||||
// case-insensitive-unique
|
||||
err := insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
}, {
|
||||
Name: "Dev",
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, "duplicate agent name")
|
||||
err = insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
}, {
|
||||
Name: "dev",
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, "duplicate agent name")
|
||||
})
|
||||
t.Run("AgentNameInvalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := dbmem.New()
|
||||
job := uuid.New()
|
||||
err := insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "Dev",
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err) // uppercase is still allowed
|
||||
err = insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev_1",
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, `agent name "dev_1" contains underscores`) // custom error for underscores
|
||||
err = insert(db, job, &sdkproto.Resource{
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev--1",
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, `agent name "dev--1" does not match regex`)
|
||||
})
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@ -1981,6 +2092,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
DisplayApps: &sdkproto.DisplayApps{
|
||||
Vscode: true,
|
||||
VscodeInsiders: true,
|
||||
@ -2009,6 +2121,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
DisplayApps: &sdkproto.DisplayApps{},
|
||||
}},
|
||||
})
|
||||
@ -2033,6 +2146,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
|
||||
Name: "something",
|
||||
Type: "aws_instance",
|
||||
Agents: []*sdkproto.Agent{{
|
||||
Name: "dev",
|
||||
DisplayApps: &sdkproto.DisplayApps{},
|
||||
ResourcesMonitoring: &sdkproto.ResourcesMonitoring{
|
||||
Memory: &sdkproto.MemoryResourceMonitor{
|
||||
|
@ -297,18 +297,17 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
Identifier: RoleAuditor(),
|
||||
DisplayName: "Auditor",
|
||||
Site: Permissions(map[string][]policy.Action{
|
||||
// Should be able to read all template details, even in orgs they
|
||||
// are not in.
|
||||
ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights},
|
||||
ResourceAuditLog.Type: {policy.ActionRead},
|
||||
ResourceUser.Type: {policy.ActionRead},
|
||||
ResourceGroup.Type: {policy.ActionRead},
|
||||
ResourceGroupMember.Type: {policy.ActionRead},
|
||||
ResourceAuditLog.Type: {policy.ActionRead},
|
||||
// Allow auditors to see the resources that audit logs reflect.
|
||||
ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights},
|
||||
ResourceUser.Type: {policy.ActionRead},
|
||||
ResourceGroup.Type: {policy.ActionRead},
|
||||
ResourceGroupMember.Type: {policy.ActionRead},
|
||||
ResourceOrganization.Type: {policy.ActionRead},
|
||||
ResourceOrganizationMember.Type: {policy.ActionRead},
|
||||
// Allow auditors to query deployment stats and insights.
|
||||
ResourceDeploymentStats.Type: {policy.ActionRead},
|
||||
ResourceDeploymentConfig.Type: {policy.ActionRead},
|
||||
// Org roles are not really used yet, so grant the perm at the site level.
|
||||
ResourceOrganizationMember.Type: {policy.ActionRead},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
User: []Permission{},
|
||||
@ -325,11 +324,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// CRUD to provisioner daemons for now.
|
||||
ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
// Needs to read all organizations since
|
||||
ResourceOrganization.Type: {policy.ActionRead},
|
||||
ResourceUser.Type: {policy.ActionRead},
|
||||
ResourceGroup.Type: {policy.ActionRead},
|
||||
ResourceGroupMember.Type: {policy.ActionRead},
|
||||
// Org roles are not really used yet, so grant the perm at the site level.
|
||||
ResourceUser.Type: {policy.ActionRead},
|
||||
ResourceGroup.Type: {policy.ActionRead},
|
||||
ResourceGroupMember.Type: {policy.ActionRead},
|
||||
ResourceOrganization.Type: {policy.ActionRead},
|
||||
ResourceOrganizationMember.Type: {policy.ActionRead},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
@ -348,10 +346,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete,
|
||||
policy.ActionUpdatePersonal, policy.ActionReadPersonal,
|
||||
},
|
||||
ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
ResourceGroupMember.Type: {policy.ActionRead},
|
||||
ResourceOrganization.Type: {policy.ActionRead},
|
||||
// Full perms to manage org members
|
||||
ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
ResourceGroupMember.Type: {policy.ActionRead},
|
||||
// Manage org membership based on OIDC claims
|
||||
ResourceIdpsyncSettings.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
}),
|
||||
|
@ -117,6 +117,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}}}
|
||||
templateAdmin := authSubject{Name: "template-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleTemplateAdmin()}}}
|
||||
userAdmin := authSubject{Name: "user-admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleUserAdmin()}}}
|
||||
auditor := authSubject{Name: "auditor", Actor: rbac.Subject{ID: auditorID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleAuditor()}}}
|
||||
|
||||
orgAdmin := authSubject{Name: "org_admin", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAdmin(orgID)}}}
|
||||
orgAuditor := authSubject{Name: "org_auditor", Actor: rbac.Subject{ID: auditorID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAuditor(orgID)}}}
|
||||
@ -286,8 +287,8 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgMemberMe, templateAdmin, orgTemplateAdmin, orgAuditor, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe, userAdmin},
|
||||
true: {owner, orgAdmin, orgMemberMe, templateAdmin, orgTemplateAdmin, auditor, orgAuditor, userAdmin, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -829,6 +829,7 @@ func TestTemplateVersionResources(t *testing.T) {
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{},
|
||||
}},
|
||||
}, {
|
||||
@ -875,7 +876,8 @@ func TestTemplateVersionLogs(t *testing.T) {
|
||||
Name: "some",
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Id: "something",
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: uuid.NewString(),
|
||||
},
|
||||
|
@ -393,7 +393,8 @@ func TestWorkspaceAgentConnectRPC(t *testing.T) {
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: uuid.NewString(),
|
||||
},
|
||||
|
@ -147,6 +147,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
|
||||
Database: api.Database,
|
||||
NotificationsEnqueuer: api.NotificationsEnqueuer,
|
||||
Pubsub: api.Pubsub,
|
||||
Auditor: &api.Auditor,
|
||||
DerpMapFn: api.DERPMap,
|
||||
TailnetCoordinator: &api.TailnetCoordinator,
|
||||
AppearanceFetcher: &api.AppearanceFetcher,
|
||||
|
@ -720,6 +720,7 @@ func TestWorkspaceBuildLogs(t *testing.T) {
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{},
|
||||
}},
|
||||
}, {
|
||||
|
@ -33,6 +33,7 @@ func TestPostWorkspaceAuthAzureInstanceIdentity(t *testing.T) {
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
@ -78,6 +79,7 @@ func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) {
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
@ -164,6 +166,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
|
@ -219,6 +219,7 @@ func TestWorkspace(t *testing.T) {
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{},
|
||||
}},
|
||||
}},
|
||||
@ -259,6 +260,7 @@ func TestWorkspace(t *testing.T) {
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{},
|
||||
ConnectionTimeoutSeconds: 1,
|
||||
}},
|
||||
@ -1722,7 +1724,8 @@ func TestWorkspaceFilterManual(t *testing.T) {
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
@ -2729,7 +2732,8 @@ func TestWorkspaceWatcher(t *testing.T) {
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
@ -2951,6 +2955,7 @@ func TestWorkspaceResource(t *testing.T) {
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{},
|
||||
Apps: apps,
|
||||
}},
|
||||
@ -3025,6 +3030,7 @@ func TestWorkspaceResource(t *testing.T) {
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{},
|
||||
Apps: apps,
|
||||
}},
|
||||
@ -3068,6 +3074,7 @@ func TestWorkspaceResource(t *testing.T) {
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{},
|
||||
}},
|
||||
Metadata: []*proto.Resource_Metadata{{
|
||||
|
Reference in New Issue
Block a user