Compare commits
19 Commits
hello-feature
...
v2.20.1
| Author | SHA1 | Date | |
|---|---|---|---|
| dba881fc7d | |||
| b615a35d43 | |||
| bdd7794e85 | |||
| 03b5012846 | |||
| a5eb06e3f4 | |||
| 8aec4f2c21 | |||
| e54e31e9f4 | |||
| 32dc903d77 | |||
| 7381f9a6c4 | |||
| 4633658d59 | |||
| 6da3c9d48c | |||
| 99a5d72a8d | |||
| fc0db40791 | |||
| b7ea479de3 | |||
| 735dc5d794 | |||
| 114cf57580 | |||
| 36186bbb78 | |||
| 780b2714ff | |||
| 34740bc242 |
@@ -1021,7 +1021,10 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-22.04' }}
|
||||
permissions:
|
||||
packages: write # Needed to push images to ghcr.io
|
||||
# Necessary to push docker images to ghcr.io.
|
||||
packages: write
|
||||
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
|
||||
id-token: write
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
outputs:
|
||||
@@ -1050,12 +1053,44 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
# Necessary for signing Windows binaries.
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "11.0"
|
||||
|
||||
- name: Install go-winres
|
||||
run: go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
|
||||
|
||||
- name: Install nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1
|
||||
|
||||
- name: Install zstd
|
||||
run: sudo apt-get install -y zstd
|
||||
|
||||
- name: Setup Windows EV Signing Certificate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
touch /tmp/ev_cert.pem
|
||||
chmod 600 /tmp/ev_cert.pem
|
||||
echo "$EV_SIGNING_CERT" > /tmp/ev_cert.pem
|
||||
wget https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar -O /tmp/jsign-6.0.jar
|
||||
env:
|
||||
EV_SIGNING_CERT: ${{ secrets.EV_SIGNING_CERT }}
|
||||
|
||||
# Setup GCloud for signing Windows binaries.
|
||||
- name: Authenticate to Google Cloud
|
||||
id: gcloud_auth
|
||||
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
token_format: "access_token"
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
@@ -1082,6 +1117,18 @@ jobs:
|
||||
build/coder_linux_{amd64,arm64,armv7} \
|
||||
build/coder_"$version"_windows_amd64.zip \
|
||||
build/coder_"$version"_linux_amd64.{tar.gz,deb}
|
||||
env:
|
||||
# The Windows slim binary must be signed for Coder Desktop to accept
|
||||
# it. The darwin executables don't need to be signed, but the dylibs
|
||||
# do (see above).
|
||||
CODER_SIGN_WINDOWS: "1"
|
||||
CODER_WINDOWS_RESOURCES: "1"
|
||||
EV_KEY: ${{ secrets.EV_KEY }}
|
||||
EV_KEYSTORE: ${{ secrets.EV_KEYSTORE }}
|
||||
EV_TSA_URL: ${{ secrets.EV_TSA_URL }}
|
||||
EV_CERTIFICATE_PATH: /tmp/ev_cert.pem
|
||||
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
|
||||
JSIGN_PATH: /tmp/jsign-6.0.jar
|
||||
|
||||
- name: Build Linux Docker images
|
||||
id: build-docker
|
||||
@@ -1183,10 +1230,10 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@af67405ee43a6cd66e0b73f4b3802e8583f9d961 # v2.5.0
|
||||
uses: fluxcd/flux2/action@8d5f40dca5aa5d3c0fc3414457dda15a0ac92fa4 # v2.5.1
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.2.1"
|
||||
version: "2.5.1"
|
||||
|
||||
- name: Get Cluster Credentials
|
||||
uses: google-github-actions/get-gke-credentials@7a108e64ed8546fe38316b4086e91da13f4785e1 # v2.3.1
|
||||
|
||||
@@ -223,21 +223,12 @@ jobs:
|
||||
distribution: "zulu"
|
||||
java-version: "11.0"
|
||||
|
||||
- name: Install go-winres
|
||||
run: go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
|
||||
|
||||
- name: Install nsis and zstd
|
||||
run: sudo apt-get install -y nsis zstd
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
|
||||
- name: Insert dylibs
|
||||
run: |
|
||||
mv ./build/*amd64.dylib ./site/out/bin/coder-vpn-darwin-amd64.dylib
|
||||
mv ./build/*arm64.dylib ./site/out/bin/coder-vpn-darwin-arm64.dylib
|
||||
mv ./build/*arm64.h ./site/out/bin/coder-vpn-darwin-dylib.h
|
||||
|
||||
- name: Install nfpm
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -294,6 +285,18 @@ jobs:
|
||||
- name: Setup GCloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
|
||||
- name: Insert dylibs
|
||||
run: |
|
||||
mv ./build/*amd64.dylib ./site/out/bin/coder-vpn-darwin-amd64.dylib
|
||||
mv ./build/*arm64.dylib ./site/out/bin/coder-vpn-darwin-arm64.dylib
|
||||
mv ./build/*arm64.h ./site/out/bin/coder-vpn-darwin-dylib.h
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -310,6 +313,7 @@ jobs:
|
||||
env:
|
||||
CODER_SIGN_WINDOWS: "1"
|
||||
CODER_SIGN_DARWIN: "1"
|
||||
CODER_WINDOWS_RESOURCES: "1"
|
||||
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
AC_APIKEY_ISSUER_ID: ${{ secrets.AC_APIKEY_ISSUER_ID }}
|
||||
|
||||
+14
-11
@@ -1193,19 +1193,22 @@ func (a *agent) createTailnet(
|
||||
return nil, xerrors.Errorf("update host signer: %w", err)
|
||||
}
|
||||
|
||||
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentSSHPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on the ssh port: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
for _, port := range []int{workspacesdk.AgentSSHPort, workspacesdk.AgentStandardSSHPort} {
|
||||
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(port))
|
||||
if err != nil {
|
||||
_ = sshListener.Close()
|
||||
return nil, xerrors.Errorf("listen on the ssh port (%v): %w", port, err)
|
||||
}
|
||||
// nolint:revive // We do want to run the deferred functions when createTailnet returns.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = sshListener.Close()
|
||||
}
|
||||
}()
|
||||
if err = a.trackGoroutine(func() {
|
||||
_ = a.sshServer.Serve(sshListener)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}()
|
||||
if err = a.trackGoroutine(func() {
|
||||
_ = a.sshServer.Serve(sshListener)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reconnectingPTYListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentReconnectingPTYPort))
|
||||
|
||||
+120
-79
@@ -61,38 +61,48 @@ func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
||||
}
|
||||
|
||||
var sshPorts = []uint16{workspacesdk.AgentSSHPort, workspacesdk.AgentStandardSSHPort}
|
||||
|
||||
// NOTE: These tests only work when your default shell is bash for some reason.
|
||||
|
||||
func TestAgent_Stats_SSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
stdin, err := session.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
var s *proto.Stats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountSsh == 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
//nolint:dogsled
|
||||
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
|
||||
sshClient, err := conn.SSHClientOnPort(ctx, port)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
stdin, err := session.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
var s *proto.Stats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountSsh == 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
|
||||
@@ -266,15 +276,23 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
|
||||
func TestAgent_SessionExec(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil)
|
||||
|
||||
command := "echo test"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo test"
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSessionOnPort(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil, port)
|
||||
|
||||
command := "echo test"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo test"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", strings.TrimSpace(string(output)))
|
||||
})
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
//nolint:tparallel // Sub tests need to run sequentially.
|
||||
@@ -384,25 +402,33 @@ func TestAgent_SessionTTYShell(t *testing.T) {
|
||||
// it seems like it could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
session := setupSSHSession(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil)
|
||||
command := "sh"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe"
|
||||
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
t.Run(fmt.Sprintf("(%d)", port), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSessionOnPort(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil, port)
|
||||
command := "sh"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe"
|
||||
}
|
||||
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
require.NoError(t, err)
|
||||
ptty := ptytest.New(t)
|
||||
session.Stdout = ptty.Output()
|
||||
session.Stderr = ptty.Output()
|
||||
session.Stdin = ptty.Input()
|
||||
err = session.Start(command)
|
||||
require.NoError(t, err)
|
||||
_ = ptty.Peek(ctx, 1) // wait for the prompt
|
||||
ptty.WriteLine("echo test")
|
||||
ptty.ExpectMatch("test")
|
||||
ptty.WriteLine("exit")
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
require.NoError(t, err)
|
||||
ptty := ptytest.New(t)
|
||||
session.Stdout = ptty.Output()
|
||||
session.Stderr = ptty.Output()
|
||||
session.Stdin = ptty.Input()
|
||||
err = session.Start(command)
|
||||
require.NoError(t, err)
|
||||
_ = ptty.Peek(ctx, 1) // wait for the prompt
|
||||
ptty.WriteLine("echo test")
|
||||
ptty.ExpectMatch("test")
|
||||
ptty.WriteLine("exit")
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAgent_SessionTTYExitCode(t *testing.T) {
|
||||
@@ -596,37 +622,41 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
|
||||
//nolint:dogsled // Allow the blank identifiers.
|
||||
conn, client, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, setSBInterval)
|
||||
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
})
|
||||
|
||||
//nolint:paralleltest // These tests need to swap the banner func.
|
||||
for i, test := range tests {
|
||||
test := test
|
||||
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
|
||||
// Set new banner func and wait for the agent to call it to update the
|
||||
// banner.
|
||||
ready := make(chan struct{}, 2)
|
||||
client.SetAnnouncementBannersFunc(func() ([]codersdk.BannerConfig, error) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return []codersdk.BannerConfig{test.banner}, nil
|
||||
})
|
||||
<-ready
|
||||
<-ready // Wait for two updates to ensure the value has propagated.
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = session.Close()
|
||||
})
|
||||
|
||||
testSessionOutput(t, session, test.expected, test.unexpected, nil)
|
||||
sshClient, err := conn.SSHClientOnPort(ctx, port)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
})
|
||||
|
||||
for i, test := range tests {
|
||||
test := test
|
||||
t.Run(fmt.Sprintf("(:%d)/%d", port, i), func(t *testing.T) {
|
||||
// Set new banner func and wait for the agent to call it to update the
|
||||
// banner.
|
||||
ready := make(chan struct{}, 2)
|
||||
client.SetAnnouncementBannersFunc(func() ([]codersdk.BannerConfig, error) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return []codersdk.BannerConfig{test.banner}, nil
|
||||
})
|
||||
<-ready
|
||||
<-ready // Wait for two updates to ensure the value has propagated.
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = session.Close()
|
||||
})
|
||||
|
||||
testSessionOutput(t, session, test.expected, test.unexpected, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2313,6 +2343,17 @@ func setupSSHSession(
|
||||
banner codersdk.BannerConfig,
|
||||
prepareFS func(fs afero.Fs),
|
||||
opts ...func(*agenttest.Client, *agent.Options),
|
||||
) *ssh.Session {
|
||||
return setupSSHSessionOnPort(t, manifest, banner, prepareFS, workspacesdk.AgentSSHPort, opts...)
|
||||
}
|
||||
|
||||
func setupSSHSessionOnPort(
|
||||
t *testing.T,
|
||||
manifest agentsdk.Manifest,
|
||||
banner codersdk.BannerConfig,
|
||||
prepareFS func(fs afero.Fs),
|
||||
port uint16,
|
||||
opts ...func(*agenttest.Client, *agent.Options),
|
||||
) *ssh.Session {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
@@ -2326,7 +2367,7 @@ func setupSSHSession(
|
||||
if prepareFS != nil {
|
||||
prepareFS(fs)
|
||||
}
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
sshClient, err := conn.SSHClientOnPort(ctx, port)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
|
||||
@@ -17,7 +17,7 @@ func Get(username string) (string, error) {
|
||||
return "", xerrors.Errorf("username is nonlocal path: %s", username)
|
||||
}
|
||||
//nolint: gosec // input checked above
|
||||
out, _ := exec.Command("dscl", ".", "-read", filepath.Join("/Users", username), "UserShell").Output()
|
||||
out, _ := exec.Command("dscl", ".", "-read", filepath.Join("/Users", username), "UserShell").Output() //nolint:gocritic
|
||||
s, ok := strings.CutPrefix(string(out), "UserShell: ")
|
||||
if ok {
|
||||
return strings.TrimSpace(s), nil
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.syso
|
||||
@@ -0,0 +1,8 @@
|
||||
// This package is used for embedding .syso resource files into the binary
|
||||
// during build and does not contain any code. During build, .syso files will be
|
||||
// dropped in this directory and then removed after the build completes.
|
||||
//
|
||||
// This package must be imported by all binaries for this to work.
|
||||
//
|
||||
// See build_go.sh for more details.
|
||||
package resources
|
||||
+3
-1
@@ -1911,8 +1911,10 @@ func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *c
|
||||
}
|
||||
|
||||
params.clientID = GithubOAuth2DefaultProviderClientID
|
||||
params.allowEveryone = GithubOAuth2DefaultProviderAllowEveryone
|
||||
params.deviceFlow = GithubOAuth2DefaultProviderDeviceFlow
|
||||
if len(params.allowOrgs) == 0 {
|
||||
params.allowEveryone = GithubOAuth2DefaultProviderAllowEveryone
|
||||
}
|
||||
|
||||
return ¶ms, nil
|
||||
}
|
||||
|
||||
+10
-1
@@ -314,6 +314,7 @@ func TestServer(t *testing.T) {
|
||||
githubDefaultProviderEnabled string
|
||||
githubClientID string
|
||||
githubClientSecret string
|
||||
allowedOrg string
|
||||
expectGithubEnabled bool
|
||||
expectGithubDefaultProviderConfigured bool
|
||||
createUserPreStart bool
|
||||
@@ -355,7 +356,9 @@ func TestServer(t *testing.T) {
|
||||
if tc.githubDefaultProviderEnabled != "" {
|
||||
args = append(args, fmt.Sprintf("--oauth2-github-default-provider-enable=%s", tc.githubDefaultProviderEnabled))
|
||||
}
|
||||
|
||||
if tc.allowedOrg != "" {
|
||||
args = append(args, fmt.Sprintf("--oauth2-github-allowed-orgs=%s", tc.allowedOrg))
|
||||
}
|
||||
inv, cfg := clitest.New(t, args...)
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -439,6 +442,12 @@ func TestServer(t *testing.T) {
|
||||
expectGithubEnabled: true,
|
||||
expectGithubDefaultProviderConfigured: false,
|
||||
},
|
||||
{
|
||||
name: "AllowedOrg",
|
||||
allowedOrg: "coder",
|
||||
expectGithubEnabled: true,
|
||||
expectGithubDefaultProviderConfigured: true,
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
_ "github.com/coder/coder/v2/buildinfo/resources"
|
||||
"github.com/coder/coder/v2/cli"
|
||||
)
|
||||
|
||||
|
||||
Generated
+2
@@ -13699,6 +13699,7 @@ const docTemplate = `{
|
||||
"read",
|
||||
"read_personal",
|
||||
"ssh",
|
||||
"unassign",
|
||||
"update",
|
||||
"update_personal",
|
||||
"use",
|
||||
@@ -13714,6 +13715,7 @@ const docTemplate = `{
|
||||
"ActionRead",
|
||||
"ActionReadPersonal",
|
||||
"ActionSSH",
|
||||
"ActionUnassign",
|
||||
"ActionUpdate",
|
||||
"ActionUpdatePersonal",
|
||||
"ActionUse",
|
||||
|
||||
Generated
+2
@@ -12388,6 +12388,7 @@
|
||||
"read",
|
||||
"read_personal",
|
||||
"ssh",
|
||||
"unassign",
|
||||
"update",
|
||||
"update_personal",
|
||||
"use",
|
||||
@@ -12403,6 +12404,7 @@
|
||||
"ActionRead",
|
||||
"ActionReadPersonal",
|
||||
"ActionSSH",
|
||||
"ActionUnassign",
|
||||
"ActionUpdate",
|
||||
"ActionUpdatePersonal",
|
||||
"ActionUse",
|
||||
|
||||
@@ -34,11 +34,12 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
canAssignRole := rbac.Role{
|
||||
canCreateCustomRole := rbac.Role{
|
||||
Identifier: rbac.RoleIdentifier{Name: "can-assign"},
|
||||
DisplayName: "",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceAssignRole.Type: {policy.ActionRead, policy.ActionCreate},
|
||||
rbac.ResourceAssignRole.Type: {policy.ActionRead},
|
||||
rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -61,17 +62,15 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
return all
|
||||
}
|
||||
|
||||
orgID := uuid.NullUUID{
|
||||
UUID: uuid.New(),
|
||||
Valid: true,
|
||||
}
|
||||
orgID := uuid.New()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
subject rbac.ExpandableRoles
|
||||
|
||||
// Perms to create on new custom role
|
||||
organizationID uuid.NullUUID
|
||||
organizationID uuid.UUID
|
||||
site []codersdk.Permission
|
||||
org []codersdk.Permission
|
||||
user []codersdk.Permission
|
||||
@@ -79,19 +78,21 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
// No roles, so no assign role
|
||||
name: "no-roles",
|
||||
subject: rbac.RoleIdentifiers{},
|
||||
errorContains: "forbidden",
|
||||
name: "no-roles",
|
||||
organizationID: orgID,
|
||||
subject: rbac.RoleIdentifiers{},
|
||||
errorContains: "forbidden",
|
||||
},
|
||||
{
|
||||
// This works because the new role has 0 perms
|
||||
name: "empty",
|
||||
subject: merge(canAssignRole),
|
||||
name: "empty",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole),
|
||||
},
|
||||
{
|
||||
name: "mixed-scopes",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
@@ -101,27 +102,30 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
errorContains: "organization roles specify site or user permissions",
|
||||
},
|
||||
{
|
||||
name: "invalid-action",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "invalid-action",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
// Action does not go with resource
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionViewInsights},
|
||||
}),
|
||||
errorContains: "invalid action",
|
||||
},
|
||||
{
|
||||
name: "invalid-resource",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "invalid-resource",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
"foobar": {codersdk.ActionViewInsights},
|
||||
}),
|
||||
errorContains: "invalid resource",
|
||||
},
|
||||
{
|
||||
// Not allowing these at this time.
|
||||
name: "negative-permission",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: []codersdk.Permission{
|
||||
name: "negative-permission",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: []codersdk.Permission{
|
||||
{
|
||||
Negate: true,
|
||||
ResourceType: codersdk.ResourceWorkspace,
|
||||
@@ -131,89 +135,69 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
errorContains: "no negative permissions",
|
||||
},
|
||||
{
|
||||
name: "wildcard", // not allowed
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "wildcard", // not allowed
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {"*"},
|
||||
}),
|
||||
errorContains: "no wildcard symbols",
|
||||
},
|
||||
// escalation checks
|
||||
{
|
||||
name: "read-workspace-escalation",
|
||||
subject: merge(canAssignRole),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "read-workspace-escalation",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
errorContains: "not allowed to grant this permission",
|
||||
},
|
||||
{
|
||||
name: "read-workspace-outside-org",
|
||||
organizationID: uuid.NullUUID{
|
||||
UUID: uuid.New(),
|
||||
Valid: true,
|
||||
},
|
||||
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
|
||||
name: "read-workspace-outside-org",
|
||||
organizationID: uuid.New(),
|
||||
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
errorContains: "forbidden",
|
||||
errorContains: "not allowed to grant this permission",
|
||||
},
|
||||
{
|
||||
name: "user-escalation",
|
||||
// These roles do not grant user perms
|
||||
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
errorContains: "not allowed to grant this permission",
|
||||
errorContains: "organization roles specify site or user permissions",
|
||||
},
|
||||
{
|
||||
name: "template-admin-escalation",
|
||||
subject: merge(canAssignRole, rbac.RoleTemplateAdmin()),
|
||||
name: "site-escalation",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok!
|
||||
codersdk.ResourceDeploymentConfig: {codersdk.ActionUpdate}, // not ok!
|
||||
}),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok!
|
||||
}),
|
||||
errorContains: "deployment_config",
|
||||
errorContains: "organization roles specify site or user permissions",
|
||||
},
|
||||
// ok!
|
||||
{
|
||||
name: "read-workspace-template-admin",
|
||||
subject: merge(canAssignRole, rbac.RoleTemplateAdmin()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "read-workspace-template-admin",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "read-workspace-in-org",
|
||||
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "user-perms",
|
||||
// This is weird, but is ok
|
||||
subject: merge(canAssignRole, rbac.RoleMember()),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "site+user-perms",
|
||||
subject: merge(canAssignRole, rbac.RoleMember(), rbac.RoleTemplateAdmin()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -234,7 +218,7 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
|
||||
Name: "test-role",
|
||||
DisplayName: "",
|
||||
OrganizationID: tc.organizationID,
|
||||
OrganizationID: uuid.NullUUID{UUID: tc.organizationID, Valid: true},
|
||||
SitePermissions: db2sdk.List(tc.site, convertSDKPerm),
|
||||
OrgPermissions: db2sdk.List(tc.org, convertSDKPerm),
|
||||
UserPermissions: db2sdk.List(tc.user, convertSDKPerm),
|
||||
@@ -249,11 +233,11 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
LookupRoles: []database.NameOrganizationPair{
|
||||
{
|
||||
Name: "test-role",
|
||||
OrganizationID: tc.organizationID.UUID,
|
||||
OrganizationID: tc.organizationID,
|
||||
},
|
||||
},
|
||||
ExcludeOrgRoles: false,
|
||||
OrganizationID: uuid.UUID{},
|
||||
OrganizationID: uuid.Nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, roles, 1)
|
||||
|
||||
@@ -747,7 +747,7 @@ func (*querier) convertToDeploymentRoles(names []string) []rbac.RoleIdentifier {
|
||||
}
|
||||
|
||||
// canAssignRoles handles assigning built in and custom roles.
|
||||
func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, removed []rbac.RoleIdentifier) error {
|
||||
func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, removed []rbac.RoleIdentifier) error {
|
||||
actor, ok := ActorFromContext(ctx)
|
||||
if !ok {
|
||||
return NoActorError
|
||||
@@ -755,12 +755,14 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
|
||||
|
||||
roleAssign := rbac.ResourceAssignRole
|
||||
shouldBeOrgRoles := false
|
||||
if orgID != nil {
|
||||
roleAssign = rbac.ResourceAssignOrgRole.InOrg(*orgID)
|
||||
if orgID != uuid.Nil {
|
||||
roleAssign = rbac.ResourceAssignOrgRole.InOrg(orgID)
|
||||
shouldBeOrgRoles = true
|
||||
}
|
||||
|
||||
grantedRoles := append(added, removed...)
|
||||
grantedRoles := make([]rbac.RoleIdentifier, 0, len(added)+len(removed))
|
||||
grantedRoles = append(grantedRoles, added...)
|
||||
grantedRoles = append(grantedRoles, removed...)
|
||||
customRoles := make([]rbac.RoleIdentifier, 0)
|
||||
// Validate that the roles being assigned are valid.
|
||||
for _, r := range grantedRoles {
|
||||
@@ -774,11 +776,11 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
|
||||
}
|
||||
|
||||
if shouldBeOrgRoles {
|
||||
if orgID == nil {
|
||||
if orgID == uuid.Nil {
|
||||
return xerrors.Errorf("should never happen, orgID is nil, but trying to assign an organization role")
|
||||
}
|
||||
|
||||
if r.OrganizationID != *orgID {
|
||||
if r.OrganizationID != orgID {
|
||||
return xerrors.Errorf("attempted to assign role from a different org, role %q to %q", r, orgID.String())
|
||||
}
|
||||
}
|
||||
@@ -824,7 +826,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
|
||||
}
|
||||
|
||||
if len(removed) > 0 {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, roleAssign); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUnassign, roleAssign); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1124,11 +1126,15 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error {
|
||||
return q.db.CleanTailnetTunnels(ctx)
|
||||
}
|
||||
|
||||
// TODO: Handle org scoped lookups
|
||||
func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAssignRole); err != nil {
|
||||
roleObject := rbac.ResourceAssignRole
|
||||
if arg.OrganizationID != uuid.Nil {
|
||||
roleObject = rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID)
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, roleObject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.CustomRoles(ctx, arg)
|
||||
}
|
||||
|
||||
@@ -1185,14 +1191,11 @@ func (q *querier) DeleteCryptoKey(ctx context.Context, arg database.DeleteCrypto
|
||||
}
|
||||
|
||||
func (q *querier) DeleteCustomRole(ctx context.Context, arg database.DeleteCustomRoleParams) error {
|
||||
if arg.OrganizationID.UUID != uuid.Nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignRole); err != nil {
|
||||
return err
|
||||
}
|
||||
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
|
||||
return NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.db.DeleteCustomRole(ctx, arg)
|
||||
@@ -1426,6 +1429,17 @@ func (q *querier) FetchMemoryResourceMonitorsByAgentID(ctx context.Context, agen
|
||||
return q.db.FetchMemoryResourceMonitorsByAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
func (q *querier) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
// Ideally, we would return a list of monitors that the user has access to. However, that check would need to
|
||||
// be implemented similarly to GetWorkspaces, which is more complex than what we're doing here. Since this query
|
||||
// was introduced for telemetry, we perform a simpler check.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
}
|
||||
|
||||
func (q *querier) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationMessage); err != nil {
|
||||
return database.FetchNewMessageMetadataRow{}, err
|
||||
@@ -1447,6 +1461,17 @@ func (q *querier) FetchVolumesResourceMonitorsByAgentID(ctx context.Context, age
|
||||
return q.db.FetchVolumesResourceMonitorsByAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
func (q *querier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
// Ideally, we would return a list of monitors that the user has access to. However, that check would need to
|
||||
// be implemented similarly to GetWorkspaces, which is more complex than what we're doing here. Since this query
|
||||
// was introduced for telemetry, we perform a simpler check.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
}
|
||||
|
||||
func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id)
|
||||
}
|
||||
@@ -3009,14 +3034,11 @@ func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCrypto
|
||||
|
||||
func (q *querier) InsertCustomRole(ctx context.Context, arg database.InsertCustomRoleParams) (database.CustomRole, error) {
|
||||
// Org and site role upsert share the same query. So switch the assertion based on the org uuid.
|
||||
if arg.OrganizationID.UUID != uuid.Nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
} else {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignRole); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
|
||||
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
|
||||
if err := q.customRoleCheck(ctx, database.CustomRole{
|
||||
@@ -3146,7 +3168,7 @@ func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.Ins
|
||||
|
||||
// All roles are added roles. Org member is always implied.
|
||||
addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID))
|
||||
err = q.canAssignRoles(ctx, &arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
|
||||
err = q.canAssignRoles(ctx, arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
|
||||
if err != nil {
|
||||
return database.OrganizationMember{}, err
|
||||
}
|
||||
@@ -3270,7 +3292,7 @@ func (q *querier) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg dat
|
||||
func (q *querier) InsertUser(ctx context.Context, arg database.InsertUserParams) (database.User, error) {
|
||||
// Always check if the assigned roles can actually be assigned by this actor.
|
||||
impliedRoles := append([]rbac.RoleIdentifier{rbac.RoleMember()}, q.convertToDeploymentRoles(arg.RBACRoles)...)
|
||||
err := q.canAssignRoles(ctx, nil, impliedRoles, []rbac.RoleIdentifier{})
|
||||
err := q.canAssignRoles(ctx, uuid.Nil, impliedRoles, []rbac.RoleIdentifier{})
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
@@ -3608,14 +3630,11 @@ func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.Upd
|
||||
}
|
||||
|
||||
func (q *querier) UpdateCustomRole(ctx context.Context, arg database.UpdateCustomRoleParams) (database.CustomRole, error) {
|
||||
if arg.OrganizationID.UUID != uuid.Nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
} else {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignRole); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
|
||||
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
|
||||
if err := q.customRoleCheck(ctx, database.CustomRole{
|
||||
@@ -3695,7 +3714,7 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
|
||||
impliedTypes := append(scopedGranted, rbac.ScopedRoleOrgMember(arg.OrgID))
|
||||
|
||||
added, removed := rbac.ChangeRoleSet(originalRoles, impliedTypes)
|
||||
err = q.canAssignRoles(ctx, &arg.OrgID, added, removed)
|
||||
err = q.canAssignRoles(ctx, arg.OrgID, added, removed)
|
||||
if err != nil {
|
||||
return database.OrganizationMember{}, err
|
||||
}
|
||||
@@ -4102,7 +4121,7 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo
|
||||
impliedTypes := append(q.convertToDeploymentRoles(arg.GrantedRoles), rbac.RoleMember())
|
||||
// If the changeset is nothing, less rbac checks need to be done.
|
||||
added, removed := rbac.ChangeRoleSet(q.convertToDeploymentRoles(user.RBACRoles), impliedTypes)
|
||||
err = q.canAssignRoles(ctx, nil, added, removed)
|
||||
err = q.canAssignRoles(ctx, uuid.Nil, added, removed)
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
|
||||
@@ -1011,7 +1011,7 @@ func (s *MethodTestSuite) TestOrganization() {
|
||||
Asserts(
|
||||
mem, policy.ActionRead,
|
||||
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, // org-mem
|
||||
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionDelete, // org-admin
|
||||
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionUnassign, // org-admin
|
||||
).Returns(out)
|
||||
}))
|
||||
}
|
||||
@@ -1619,7 +1619,7 @@ func (s *MethodTestSuite) TestUser() {
|
||||
}).Asserts(
|
||||
u, policy.ActionRead,
|
||||
rbac.ResourceAssignRole, policy.ActionAssign,
|
||||
rbac.ResourceAssignRole, policy.ActionDelete,
|
||||
rbac.ResourceAssignRole, policy.ActionUnassign,
|
||||
).Returns(o)
|
||||
}))
|
||||
s.Run("AllUserIDs", s.Subtest(func(db database.Store, check *expects) {
|
||||
@@ -1653,30 +1653,28 @@ func (s *MethodTestSuite) TestUser() {
|
||||
check.Args(database.DeleteCustomRoleParams{
|
||||
Name: customRole.Name,
|
||||
}).Asserts(
|
||||
rbac.ResourceAssignRole, policy.ActionDelete)
|
||||
// fails immediately, missing organization id
|
||||
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
|
||||
}))
|
||||
s.Run("Blank/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
|
||||
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{})
|
||||
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
|
||||
OrganizationID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
})
|
||||
// Blank is no perms in the role
|
||||
check.Args(database.UpdateCustomRoleParams{
|
||||
Name: customRole.Name,
|
||||
DisplayName: "Test Name",
|
||||
OrganizationID: customRole.OrganizationID,
|
||||
SitePermissions: nil,
|
||||
OrgPermissions: nil,
|
||||
UserPermissions: nil,
|
||||
}).Asserts(rbac.ResourceAssignRole, policy.ActionUpdate).ErrorsWithPG(sql.ErrNoRows)
|
||||
}).Asserts(rbac.ResourceAssignOrgRole.InOrg(customRole.OrganizationID.UUID), policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("SitePermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: uuid.Nil,
|
||||
Valid: false,
|
||||
},
|
||||
})
|
||||
check.Args(database.UpdateCustomRoleParams{
|
||||
Name: customRole.Name,
|
||||
OrganizationID: customRole.OrganizationID,
|
||||
Name: "",
|
||||
OrganizationID: uuid.NullUUID{UUID: uuid.Nil, Valid: false},
|
||||
DisplayName: "Test Name",
|
||||
SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights},
|
||||
@@ -1686,17 +1684,8 @@ func (s *MethodTestSuite) TestUser() {
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}).Asserts(
|
||||
// First check
|
||||
rbac.ResourceAssignRole, policy.ActionUpdate,
|
||||
// Escalation checks
|
||||
rbac.ResourceTemplate, policy.ActionCreate,
|
||||
rbac.ResourceTemplate, policy.ActionRead,
|
||||
rbac.ResourceTemplate, policy.ActionUpdate,
|
||||
rbac.ResourceTemplate, policy.ActionDelete,
|
||||
rbac.ResourceTemplate, policy.ActionViewInsights,
|
||||
|
||||
rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead,
|
||||
).ErrorsWithPG(sql.ErrNoRows)
|
||||
// fails immediately, missing organization id
|
||||
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
|
||||
}))
|
||||
s.Run("OrgPermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
orgID := uuid.New()
|
||||
@@ -1726,13 +1715,15 @@ func (s *MethodTestSuite) TestUser() {
|
||||
}))
|
||||
s.Run("Blank/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
// Blank is no perms in the role
|
||||
orgID := uuid.New()
|
||||
check.Args(database.InsertCustomRoleParams{
|
||||
Name: "test",
|
||||
DisplayName: "Test Name",
|
||||
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
|
||||
SitePermissions: nil,
|
||||
OrgPermissions: nil,
|
||||
UserPermissions: nil,
|
||||
}).Asserts(rbac.ResourceAssignRole, policy.ActionCreate)
|
||||
}).Asserts(rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionCreate)
|
||||
}))
|
||||
s.Run("SitePermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(database.InsertCustomRoleParams{
|
||||
@@ -1746,17 +1737,8 @@ func (s *MethodTestSuite) TestUser() {
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}).Asserts(
|
||||
// First check
|
||||
rbac.ResourceAssignRole, policy.ActionCreate,
|
||||
// Escalation checks
|
||||
rbac.ResourceTemplate, policy.ActionCreate,
|
||||
rbac.ResourceTemplate, policy.ActionRead,
|
||||
rbac.ResourceTemplate, policy.ActionUpdate,
|
||||
rbac.ResourceTemplate, policy.ActionDelete,
|
||||
rbac.ResourceTemplate, policy.ActionViewInsights,
|
||||
|
||||
rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead,
|
||||
)
|
||||
// fails immediately, missing organization id
|
||||
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
|
||||
}))
|
||||
s.Run("OrgPermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
orgID := uuid.New()
|
||||
@@ -4802,6 +4784,14 @@ func (s *MethodTestSuite) TestResourcesMonitor() {
|
||||
}).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionUpdate)
|
||||
}))
|
||||
|
||||
s.Run("FetchMemoryResourceMonitorsUpdatedAfter", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(dbtime.Now()).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("FetchVolumesResourceMonitorsUpdatedAfter", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(dbtime.Now()).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("FetchMemoryResourceMonitorsByAgentID", s.Subtest(func(db database.Store, check *expects) {
|
||||
agt, w := createAgent(s.T(), db)
|
||||
|
||||
|
||||
@@ -2361,6 +2361,19 @@ func (q *FakeQuerier) FetchMemoryResourceMonitorsByAgentID(_ context.Context, ag
|
||||
return database.WorkspaceAgentMemoryResourceMonitor{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) FetchMemoryResourceMonitorsUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
monitors := []database.WorkspaceAgentMemoryResourceMonitor{}
|
||||
for _, monitor := range q.workspaceAgentMemoryResourceMonitors {
|
||||
if monitor.UpdatedAt.After(updatedAt) {
|
||||
monitors = append(monitors, monitor)
|
||||
}
|
||||
}
|
||||
return monitors, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) FetchNewMessageMetadata(_ context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
@@ -2405,6 +2418,19 @@ func (q *FakeQuerier) FetchVolumesResourceMonitorsByAgentID(_ context.Context, a
|
||||
return monitors, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) FetchVolumesResourceMonitorsUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
monitors := []database.WorkspaceAgentVolumeResourceMonitor{}
|
||||
for _, monitor := range q.workspaceAgentVolumeResourceMonitors {
|
||||
if monitor.UpdatedAt.After(updatedAt) {
|
||||
monitors = append(monitors, monitor)
|
||||
}
|
||||
}
|
||||
return monitors, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
@@ -444,6 +444,13 @@ func (m queryMetricsStore) FetchMemoryResourceMonitorsByAgentID(ctx context.Cont
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
m.queryLatencies.WithLabelValues("FetchMemoryResourceMonitorsUpdatedAfter").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.FetchNewMessageMetadata(ctx, arg)
|
||||
@@ -458,6 +465,13 @@ func (m queryMetricsStore) FetchVolumesResourceMonitorsByAgentID(ctx context.Con
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
m.queryLatencies.WithLabelValues("FetchVolumesResourceMonitorsUpdatedAfter").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
|
||||
start := time.Now()
|
||||
apiKey, err := m.s.GetAPIKeyByID(ctx, id)
|
||||
|
||||
@@ -772,6 +772,21 @@ func (mr *MockStoreMockRecorder) FetchMemoryResourceMonitorsByAgentID(ctx, agent
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchMemoryResourceMonitorsByAgentID", reflect.TypeOf((*MockStore)(nil).FetchMemoryResourceMonitorsByAgentID), ctx, agentID)
|
||||
}
|
||||
|
||||
// FetchMemoryResourceMonitorsUpdatedAfter mocks base method.
|
||||
func (m *MockStore) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "FetchMemoryResourceMonitorsUpdatedAfter", ctx, updatedAt)
|
||||
ret0, _ := ret[0].([]database.WorkspaceAgentMemoryResourceMonitor)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// FetchMemoryResourceMonitorsUpdatedAfter indicates an expected call of FetchMemoryResourceMonitorsUpdatedAfter.
|
||||
func (mr *MockStoreMockRecorder) FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchMemoryResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchMemoryResourceMonitorsUpdatedAfter), ctx, updatedAt)
|
||||
}
|
||||
|
||||
// FetchNewMessageMetadata mocks base method.
|
||||
func (m *MockStore) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -802,6 +817,21 @@ func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsByAgentID(ctx, agen
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsByAgentID", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsByAgentID), ctx, agentID)
|
||||
}
|
||||
|
||||
// FetchVolumesResourceMonitorsUpdatedAfter mocks base method.
|
||||
func (m *MockStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "FetchVolumesResourceMonitorsUpdatedAfter", ctx, updatedAt)
|
||||
ret0, _ := ret[0].([]database.WorkspaceAgentVolumeResourceMonitor)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// FetchVolumesResourceMonitorsUpdatedAfter indicates an expected call of FetchVolumesResourceMonitorsUpdatedAfter.
|
||||
func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsUpdatedAfter), ctx, updatedAt)
|
||||
}
|
||||
|
||||
// GetAPIKeyByID mocks base method.
|
||||
func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -112,9 +112,11 @@ type sqlcQuerier interface {
|
||||
EnqueueNotificationMessage(ctx context.Context, arg EnqueueNotificationMessageParams) error
|
||||
FavoriteWorkspace(ctx context.Context, id uuid.UUID) error
|
||||
FetchMemoryResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) (WorkspaceAgentMemoryResourceMonitor, error)
|
||||
FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentMemoryResourceMonitor, error)
|
||||
// This is used to build up the notification_message's JSON payload.
|
||||
FetchNewMessageMetadata(ctx context.Context, arg FetchNewMessageMetadataParams) (FetchNewMessageMetadataRow, error)
|
||||
FetchVolumesResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentVolumeResourceMonitor, error)
|
||||
FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error)
|
||||
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
|
||||
// there is no unique constraint on empty token names
|
||||
GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error)
|
||||
|
||||
+109
-28
@@ -7775,25 +7775,25 @@ SELECT
|
||||
FROM
|
||||
custom_roles
|
||||
WHERE
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length($1 :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY ($1::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN $2 :: boolean THEN
|
||||
organization_id IS null
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length($1 :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY ($1::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = $3
|
||||
ELSE true
|
||||
END
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN $2 :: boolean THEN
|
||||
organization_id IS null
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = $3
|
||||
ELSE true
|
||||
END
|
||||
`
|
||||
|
||||
type CustomRolesParams struct {
|
||||
@@ -7866,16 +7866,16 @@ INSERT INTO
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
-- Always force lowercase names
|
||||
lower($1),
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
-- Always force lowercase names
|
||||
lower($1),
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id
|
||||
`
|
||||
|
||||
@@ -12135,6 +12135,46 @@ func (q *sqlQuerier) FetchMemoryResourceMonitorsByAgentID(ctx context.Context, a
|
||||
return i, err
|
||||
}
|
||||
|
||||
const fetchMemoryResourceMonitorsUpdatedAfter = `-- name: FetchMemoryResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
agent_id, enabled, threshold, created_at, updated_at, state, debounced_until
|
||||
FROM
|
||||
workspace_agent_memory_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
rows, err := q.db.QueryContext(ctx, fetchMemoryResourceMonitorsUpdatedAfter, updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceAgentMemoryResourceMonitor
|
||||
for rows.Next() {
|
||||
var i WorkspaceAgentMemoryResourceMonitor
|
||||
if err := rows.Scan(
|
||||
&i.AgentID,
|
||||
&i.Enabled,
|
||||
&i.Threshold,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.State,
|
||||
&i.DebouncedUntil,
|
||||
); 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 fetchVolumesResourceMonitorsByAgentID = `-- name: FetchVolumesResourceMonitorsByAgentID :many
|
||||
SELECT
|
||||
agent_id, enabled, threshold, path, created_at, updated_at, state, debounced_until
|
||||
@@ -12176,6 +12216,47 @@ func (q *sqlQuerier) FetchVolumesResourceMonitorsByAgentID(ctx context.Context,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const fetchVolumesResourceMonitorsUpdatedAfter = `-- name: FetchVolumesResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
agent_id, enabled, threshold, path, created_at, updated_at, state, debounced_until
|
||||
FROM
|
||||
workspace_agent_volume_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
rows, err := q.db.QueryContext(ctx, fetchVolumesResourceMonitorsUpdatedAfter, updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceAgentVolumeResourceMonitor
|
||||
for rows.Next() {
|
||||
var i WorkspaceAgentVolumeResourceMonitor
|
||||
if err := rows.Scan(
|
||||
&i.AgentID,
|
||||
&i.Enabled,
|
||||
&i.Threshold,
|
||||
&i.Path,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.State,
|
||||
&i.DebouncedUntil,
|
||||
); 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 insertMemoryResourceMonitor = `-- name: InsertMemoryResourceMonitor :one
|
||||
INSERT INTO
|
||||
workspace_agent_memory_resource_monitors (
|
||||
|
||||
@@ -4,25 +4,25 @@ SELECT
|
||||
FROM
|
||||
custom_roles
|
||||
WHERE
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length(@lookup_roles :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY (@lookup_roles::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN @exclude_org_roles :: boolean THEN
|
||||
organization_id IS null
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length(@lookup_roles :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY (@lookup_roles::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = @organization_id
|
||||
ELSE true
|
||||
END
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN @exclude_org_roles :: boolean THEN
|
||||
organization_id IS null
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = @organization_id
|
||||
ELSE true
|
||||
END
|
||||
;
|
||||
|
||||
-- name: DeleteCustomRole :exec
|
||||
@@ -46,16 +46,16 @@ INSERT INTO
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
-- Always force lowercase names
|
||||
lower(@name),
|
||||
@display_name,
|
||||
@organization_id,
|
||||
@site_permissions,
|
||||
@org_permissions,
|
||||
@user_permissions,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
-- Always force lowercase names
|
||||
lower(@name),
|
||||
@display_name,
|
||||
@organization_id,
|
||||
@site_permissions,
|
||||
@org_permissions,
|
||||
@user_permissions,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateCustomRole :one
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
-- name: FetchVolumesResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_agent_volume_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1;
|
||||
|
||||
-- name: FetchMemoryResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_agent_memory_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1;
|
||||
|
||||
-- name: FetchMemoryResourceMonitorsByAgentID :one
|
||||
SELECT
|
||||
*
|
||||
|
||||
@@ -151,11 +151,13 @@ func ResourceNotFound(rw http.ResponseWriter) {
|
||||
Write(context.Background(), rw, http.StatusNotFound, ResourceNotFoundResponse)
|
||||
}
|
||||
|
||||
var ResourceForbiddenResponse = codersdk.Response{
|
||||
Message: "Forbidden.",
|
||||
Detail: "You don't have permission to view this content. If you believe this is a mistake, please contact your administrator or try signing in with different credentials.",
|
||||
}
|
||||
|
||||
func Forbidden(rw http.ResponseWriter) {
|
||||
Write(context.Background(), rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Forbidden.",
|
||||
Detail: "You don't have permission to view this content. If you believe this is a mistake, please contact your administrator or try signing in with different credentials.",
|
||||
})
|
||||
Write(context.Background(), rw, http.StatusForbidden, ResourceForbiddenResponse)
|
||||
}
|
||||
|
||||
func InternalServerError(rw http.ResponseWriter, err error) {
|
||||
|
||||
+1
-1
@@ -323,7 +323,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d
|
||||
customRoles, err := db.CustomRoles(ctx, database.CustomRolesParams{
|
||||
LookupRoles: roleLookup,
|
||||
ExcludeOrgRoles: false,
|
||||
OrganizationID: uuid.UUID{},
|
||||
OrganizationID: uuid.Nil,
|
||||
})
|
||||
if err != nil {
|
||||
// We are missing the display names, but that is not absolutely required. So just
|
||||
|
||||
@@ -27,22 +27,21 @@ var (
|
||||
|
||||
// ResourceAssignOrgRole
|
||||
// Valid Actions
|
||||
// - "ActionAssign" :: ability to assign org scoped roles
|
||||
// - "ActionCreate" :: ability to create/delete custom roles within an organization
|
||||
// - "ActionDelete" :: ability to delete org scoped roles
|
||||
// - "ActionRead" :: view what roles are assignable
|
||||
// - "ActionUpdate" :: ability to edit custom roles within an organization
|
||||
// - "ActionAssign" :: assign org scoped roles
|
||||
// - "ActionCreate" :: create/delete custom roles within an organization
|
||||
// - "ActionDelete" :: delete roles within an organization
|
||||
// - "ActionRead" :: view what roles are assignable within an organization
|
||||
// - "ActionUnassign" :: unassign org scoped roles
|
||||
// - "ActionUpdate" :: edit custom roles within an organization
|
||||
ResourceAssignOrgRole = Object{
|
||||
Type: "assign_org_role",
|
||||
}
|
||||
|
||||
// ResourceAssignRole
|
||||
// Valid Actions
|
||||
// - "ActionAssign" :: ability to assign roles
|
||||
// - "ActionCreate" :: ability to create/delete/edit custom roles
|
||||
// - "ActionDelete" :: ability to unassign roles
|
||||
// - "ActionAssign" :: assign user roles
|
||||
// - "ActionRead" :: view what roles are assignable
|
||||
// - "ActionUpdate" :: ability to edit custom roles
|
||||
// - "ActionUnassign" :: unassign user roles
|
||||
ResourceAssignRole = Object{
|
||||
Type: "assign_role",
|
||||
}
|
||||
@@ -367,6 +366,7 @@ func AllActions() []policy.Action {
|
||||
policy.ActionRead,
|
||||
policy.ActionReadPersonal,
|
||||
policy.ActionSSH,
|
||||
policy.ActionUnassign,
|
||||
policy.ActionUpdate,
|
||||
policy.ActionUpdatePersonal,
|
||||
policy.ActionUse,
|
||||
|
||||
@@ -19,7 +19,8 @@ const (
|
||||
ActionWorkspaceStart Action = "start"
|
||||
ActionWorkspaceStop Action = "stop"
|
||||
|
||||
ActionAssign Action = "assign"
|
||||
ActionAssign Action = "assign"
|
||||
ActionUnassign Action = "unassign"
|
||||
|
||||
ActionReadPersonal Action = "read_personal"
|
||||
ActionUpdatePersonal Action = "update_personal"
|
||||
@@ -221,20 +222,19 @@ var RBACPermissions = map[string]PermissionDefinition{
|
||||
},
|
||||
"assign_role": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionAssign: actDef("ability to assign roles"),
|
||||
ActionRead: actDef("view what roles are assignable"),
|
||||
ActionDelete: actDef("ability to unassign roles"),
|
||||
ActionCreate: actDef("ability to create/delete/edit custom roles"),
|
||||
ActionUpdate: actDef("ability to edit custom roles"),
|
||||
ActionAssign: actDef("assign user roles"),
|
||||
ActionUnassign: actDef("unassign user roles"),
|
||||
ActionRead: actDef("view what roles are assignable"),
|
||||
},
|
||||
},
|
||||
"assign_org_role": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionAssign: actDef("ability to assign org scoped roles"),
|
||||
ActionRead: actDef("view what roles are assignable"),
|
||||
ActionDelete: actDef("ability to delete org scoped roles"),
|
||||
ActionCreate: actDef("ability to create/delete custom roles within an organization"),
|
||||
ActionUpdate: actDef("ability to edit custom roles within an organization"),
|
||||
ActionAssign: actDef("assign org scoped roles"),
|
||||
ActionUnassign: actDef("unassign org scoped roles"),
|
||||
ActionCreate: actDef("create/delete custom roles within an organization"),
|
||||
ActionRead: actDef("view what roles are assignable within an organization"),
|
||||
ActionUpdate: actDef("edit custom roles within an organization"),
|
||||
ActionDelete: actDef("delete roles within an organization"),
|
||||
},
|
||||
},
|
||||
"oauth2_app": {
|
||||
|
||||
+79
-40
@@ -27,11 +27,12 @@ const (
|
||||
customSiteRole string = "custom-site-role"
|
||||
customOrganizationRole string = "custom-organization-role"
|
||||
|
||||
orgAdmin string = "organization-admin"
|
||||
orgMember string = "organization-member"
|
||||
orgAuditor string = "organization-auditor"
|
||||
orgUserAdmin string = "organization-user-admin"
|
||||
orgTemplateAdmin string = "organization-template-admin"
|
||||
orgAdmin string = "organization-admin"
|
||||
orgMember string = "organization-member"
|
||||
orgAuditor string = "organization-auditor"
|
||||
orgUserAdmin string = "organization-user-admin"
|
||||
orgTemplateAdmin string = "organization-template-admin"
|
||||
orgWorkspaceCreationBan string = "organization-workspace-creation-ban"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -159,6 +160,10 @@ func RoleOrgTemplateAdmin() string {
|
||||
return orgTemplateAdmin
|
||||
}
|
||||
|
||||
func RoleOrgWorkspaceCreationBan() string {
|
||||
return orgWorkspaceCreationBan
|
||||
}
|
||||
|
||||
// ScopedRoleOrgAdmin is the org role with the organization ID
|
||||
func ScopedRoleOrgAdmin(organizationID uuid.UUID) RoleIdentifier {
|
||||
return RoleIdentifier{Name: RoleOrgAdmin(), OrganizationID: organizationID}
|
||||
@@ -181,6 +186,10 @@ func ScopedRoleOrgTemplateAdmin(organizationID uuid.UUID) RoleIdentifier {
|
||||
return RoleIdentifier{Name: RoleOrgTemplateAdmin(), OrganizationID: organizationID}
|
||||
}
|
||||
|
||||
func ScopedRoleOrgWorkspaceCreationBan(organizationID uuid.UUID) RoleIdentifier {
|
||||
return RoleIdentifier{Name: RoleOrgWorkspaceCreationBan(), OrganizationID: organizationID}
|
||||
}
|
||||
|
||||
func allPermsExcept(excepts ...Objecter) []Permission {
|
||||
resources := AllResources()
|
||||
var perms []Permission
|
||||
@@ -298,7 +307,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
Identifier: RoleAuditor(),
|
||||
DisplayName: "Auditor",
|
||||
Site: Permissions(map[string][]policy.Action{
|
||||
ResourceAuditLog.Type: {policy.ActionRead},
|
||||
ResourceAssignOrgRole.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},
|
||||
@@ -318,7 +328,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
Identifier: RoleTemplateAdmin(),
|
||||
DisplayName: "Template Admin",
|
||||
Site: Permissions(map[string][]policy.Action{
|
||||
ResourceTemplate.Type: ResourceTemplate.AvailableActions(),
|
||||
ResourceAssignOrgRole.Type: {policy.ActionRead},
|
||||
ResourceTemplate.Type: ResourceTemplate.AvailableActions(),
|
||||
// CRUD all files, even those they did not upload.
|
||||
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
|
||||
ResourceWorkspace.Type: {policy.ActionRead},
|
||||
@@ -339,10 +350,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
Identifier: RoleUserAdmin(),
|
||||
DisplayName: "User Admin",
|
||||
Site: Permissions(map[string][]policy.Action{
|
||||
ResourceAssignRole.Type: {policy.ActionAssign, policy.ActionDelete, policy.ActionRead},
|
||||
ResourceAssignRole.Type: {policy.ActionAssign, policy.ActionUnassign, policy.ActionRead},
|
||||
// Need organization assign as well to create users. At present, creating a user
|
||||
// will always assign them to some organization.
|
||||
ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionDelete, policy.ActionRead},
|
||||
ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionUnassign, policy.ActionRead},
|
||||
ResourceUser.Type: {
|
||||
policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete,
|
||||
policy.ActionUpdatePersonal, policy.ActionReadPersonal,
|
||||
@@ -459,7 +470,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
Org: map[string][]Permission{
|
||||
organizationID.String(): Permissions(map[string][]policy.Action{
|
||||
// Assign, remove, and read roles in the organization.
|
||||
ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionDelete, policy.ActionRead},
|
||||
ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionUnassign, policy.ActionRead},
|
||||
ResourceOrganization.Type: {policy.ActionRead},
|
||||
ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
ResourceGroup.Type: ResourceGroup.AvailableActions(),
|
||||
@@ -496,6 +507,31 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
User: []Permission{},
|
||||
}
|
||||
},
|
||||
// orgWorkspaceCreationBan prevents creating & deleting workspaces. This
|
||||
// overrides any permissions granted by the org or user level. It accomplishes
|
||||
// this by using negative permissions.
|
||||
orgWorkspaceCreationBan: func(organizationID uuid.UUID) Role {
|
||||
return Role{
|
||||
Identifier: RoleIdentifier{Name: orgWorkspaceCreationBan, OrganizationID: organizationID},
|
||||
DisplayName: "Organization Workspace Creation Ban",
|
||||
Site: []Permission{},
|
||||
Org: map[string][]Permission{
|
||||
organizationID.String(): {
|
||||
{
|
||||
Negate: true,
|
||||
ResourceType: ResourceWorkspace.Type,
|
||||
Action: policy.ActionCreate,
|
||||
},
|
||||
{
|
||||
Negate: true,
|
||||
ResourceType: ResourceWorkspace.Type,
|
||||
Action: policy.ActionDelete,
|
||||
},
|
||||
},
|
||||
},
|
||||
User: []Permission{},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,44 +542,47 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// map[actor_role][assign_role]<can_assign>
|
||||
var assignRoles = map[string]map[string]bool{
|
||||
"system": {
|
||||
owner: true,
|
||||
auditor: true,
|
||||
member: true,
|
||||
orgAdmin: true,
|
||||
orgMember: true,
|
||||
orgAuditor: true,
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
customOrganizationRole: true,
|
||||
owner: true,
|
||||
auditor: true,
|
||||
member: true,
|
||||
orgAdmin: true,
|
||||
orgMember: true,
|
||||
orgAuditor: true,
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
customOrganizationRole: true,
|
||||
},
|
||||
owner: {
|
||||
owner: true,
|
||||
auditor: true,
|
||||
member: true,
|
||||
orgAdmin: true,
|
||||
orgMember: true,
|
||||
orgAuditor: true,
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
customOrganizationRole: true,
|
||||
owner: true,
|
||||
auditor: true,
|
||||
member: true,
|
||||
orgAdmin: true,
|
||||
orgMember: true,
|
||||
orgAuditor: true,
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
customOrganizationRole: true,
|
||||
},
|
||||
userAdmin: {
|
||||
member: true,
|
||||
orgMember: true,
|
||||
},
|
||||
orgAdmin: {
|
||||
orgAdmin: true,
|
||||
orgMember: true,
|
||||
orgAuditor: true,
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
customOrganizationRole: true,
|
||||
orgAdmin: true,
|
||||
orgMember: true,
|
||||
orgAuditor: true,
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
customOrganizationRole: true,
|
||||
},
|
||||
orgUserAdmin: {
|
||||
orgMember: true,
|
||||
|
||||
+22
-10
@@ -112,6 +112,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
// Subjects to user
|
||||
memberMe := authSubject{Name: "member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember()}}}
|
||||
orgMemberMe := authSubject{Name: "org_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID)}}}
|
||||
orgMemberMeBanWorkspace := authSubject{Name: "org_member_me_workspace_ban", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgWorkspaceCreationBan(orgID)}}}
|
||||
groupMemberMe := authSubject{Name: "group_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID)}, Groups: []string{groupID.String()}}}
|
||||
|
||||
owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}}}
|
||||
@@ -181,20 +182,30 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgMemberMe, orgAdmin, templateAdmin, orgTemplateAdmin},
|
||||
true: {owner, orgMemberMe, orgAdmin, templateAdmin, orgTemplateAdmin, orgMemberMeBanWorkspace},
|
||||
false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "C_RDMyWorkspaceInOrg",
|
||||
Name: "UpdateMyWorkspaceInOrg",
|
||||
// When creating the WithID won't be set, but it does not change the result.
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
|
||||
Actions: []policy.Action{policy.ActionUpdate},
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgMemberMe, orgAdmin},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "CreateDeleteMyWorkspaceInOrg",
|
||||
// When creating the WithID won't be set, but it does not change the result.
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionDelete},
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgMemberMe, orgAdmin},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgMemberMeBanWorkspace},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "MyWorkspaceInOrgExecution",
|
||||
// When creating the WithID won't be set, but it does not change the result.
|
||||
@@ -292,9 +303,9 @@ func TestRolePermissions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "CreateCustomRole",
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate},
|
||||
Resource: rbac.ResourceAssignRole,
|
||||
Name: "CreateUpdateDeleteCustomRole",
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
|
||||
Resource: rbac.ResourceAssignOrgRole,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, userAdmin, orgMemberMe, memberMe, templateAdmin},
|
||||
@@ -302,7 +313,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "RoleAssignment",
|
||||
Actions: []policy.Action{policy.ActionAssign, policy.ActionDelete},
|
||||
Actions: []policy.Action{policy.ActionAssign, policy.ActionUnassign},
|
||||
Resource: rbac.ResourceAssignRole,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, userAdmin},
|
||||
@@ -320,7 +331,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "OrgRoleAssignment",
|
||||
Actions: []policy.Action{policy.ActionAssign, policy.ActionDelete},
|
||||
Actions: []policy.Action{policy.ActionAssign, policy.ActionUnassign},
|
||||
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
|
||||
@@ -341,8 +352,8 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, setOrgNotMe, orgMemberMe, userAdmin},
|
||||
false: {setOtherOrg, memberMe, templateAdmin},
|
||||
true: {owner, setOrgNotMe, orgMemberMe, userAdmin, templateAdmin},
|
||||
false: {setOtherOrg, memberMe},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -942,6 +953,7 @@ func TestListRoles(t *testing.T) {
|
||||
fmt.Sprintf("organization-auditor:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-user-admin:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-template-admin:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-workspace-creation-ban:%s", orgID.String()),
|
||||
},
|
||||
orgRoleNames)
|
||||
}
|
||||
|
||||
@@ -624,6 +624,28 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
eg.Go(func() error {
|
||||
memoryMonitors, err := r.options.Database.FetchMemoryResourceMonitorsUpdatedAfter(ctx, createdAfter)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get memory resource monitors: %w", err)
|
||||
}
|
||||
snapshot.WorkspaceAgentMemoryResourceMonitors = make([]WorkspaceAgentMemoryResourceMonitor, 0, len(memoryMonitors))
|
||||
for _, monitor := range memoryMonitors {
|
||||
snapshot.WorkspaceAgentMemoryResourceMonitors = append(snapshot.WorkspaceAgentMemoryResourceMonitors, ConvertWorkspaceAgentMemoryResourceMonitor(monitor))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
eg.Go(func() error {
|
||||
volumeMonitors, err := r.options.Database.FetchVolumesResourceMonitorsUpdatedAfter(ctx, createdAfter)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get volume resource monitors: %w", err)
|
||||
}
|
||||
snapshot.WorkspaceAgentVolumeResourceMonitors = make([]WorkspaceAgentVolumeResourceMonitor, 0, len(volumeMonitors))
|
||||
for _, monitor := range volumeMonitors {
|
||||
snapshot.WorkspaceAgentVolumeResourceMonitors = append(snapshot.WorkspaceAgentVolumeResourceMonitors, ConvertWorkspaceAgentVolumeResourceMonitor(monitor))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
eg.Go(func() error {
|
||||
proxies, err := r.options.Database.GetWorkspaceProxies(ctx)
|
||||
if err != nil {
|
||||
@@ -765,6 +787,26 @@ func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent {
|
||||
return snapAgent
|
||||
}
|
||||
|
||||
func ConvertWorkspaceAgentMemoryResourceMonitor(monitor database.WorkspaceAgentMemoryResourceMonitor) WorkspaceAgentMemoryResourceMonitor {
|
||||
return WorkspaceAgentMemoryResourceMonitor{
|
||||
AgentID: monitor.AgentID,
|
||||
Enabled: monitor.Enabled,
|
||||
Threshold: monitor.Threshold,
|
||||
CreatedAt: monitor.CreatedAt,
|
||||
UpdatedAt: monitor.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertWorkspaceAgentVolumeResourceMonitor(monitor database.WorkspaceAgentVolumeResourceMonitor) WorkspaceAgentVolumeResourceMonitor {
|
||||
return WorkspaceAgentVolumeResourceMonitor{
|
||||
AgentID: monitor.AgentID,
|
||||
Enabled: monitor.Enabled,
|
||||
Threshold: monitor.Threshold,
|
||||
CreatedAt: monitor.CreatedAt,
|
||||
UpdatedAt: monitor.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertWorkspaceAgentStat anonymizes a workspace agent stat.
|
||||
func ConvertWorkspaceAgentStat(stat database.GetWorkspaceAgentStatsRow) WorkspaceAgentStat {
|
||||
return WorkspaceAgentStat{
|
||||
@@ -1083,28 +1125,30 @@ func ConvertTelemetryItem(item database.TelemetryItem) TelemetryItem {
|
||||
type Snapshot struct {
|
||||
DeploymentID string `json:"deployment_id"`
|
||||
|
||||
APIKeys []APIKey `json:"api_keys"`
|
||||
CLIInvocations []clitelemetry.Invocation `json:"cli_invocations"`
|
||||
ExternalProvisioners []ExternalProvisioner `json:"external_provisioners"`
|
||||
Licenses []License `json:"licenses"`
|
||||
ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"`
|
||||
TemplateVersions []TemplateVersion `json:"template_versions"`
|
||||
Templates []Template `json:"templates"`
|
||||
Users []User `json:"users"`
|
||||
Groups []Group `json:"groups"`
|
||||
GroupMembers []GroupMember `json:"group_members"`
|
||||
WorkspaceAgentStats []WorkspaceAgentStat `json:"workspace_agent_stats"`
|
||||
WorkspaceAgents []WorkspaceAgent `json:"workspace_agents"`
|
||||
WorkspaceApps []WorkspaceApp `json:"workspace_apps"`
|
||||
WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"`
|
||||
WorkspaceProxies []WorkspaceProxy `json:"workspace_proxies"`
|
||||
WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"`
|
||||
WorkspaceResources []WorkspaceResource `json:"workspace_resources"`
|
||||
WorkspaceModules []WorkspaceModule `json:"workspace_modules"`
|
||||
Workspaces []Workspace `json:"workspaces"`
|
||||
NetworkEvents []NetworkEvent `json:"network_events"`
|
||||
Organizations []Organization `json:"organizations"`
|
||||
TelemetryItems []TelemetryItem `json:"telemetry_items"`
|
||||
APIKeys []APIKey `json:"api_keys"`
|
||||
CLIInvocations []clitelemetry.Invocation `json:"cli_invocations"`
|
||||
ExternalProvisioners []ExternalProvisioner `json:"external_provisioners"`
|
||||
Licenses []License `json:"licenses"`
|
||||
ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"`
|
||||
TemplateVersions []TemplateVersion `json:"template_versions"`
|
||||
Templates []Template `json:"templates"`
|
||||
Users []User `json:"users"`
|
||||
Groups []Group `json:"groups"`
|
||||
GroupMembers []GroupMember `json:"group_members"`
|
||||
WorkspaceAgentStats []WorkspaceAgentStat `json:"workspace_agent_stats"`
|
||||
WorkspaceAgents []WorkspaceAgent `json:"workspace_agents"`
|
||||
WorkspaceApps []WorkspaceApp `json:"workspace_apps"`
|
||||
WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"`
|
||||
WorkspaceProxies []WorkspaceProxy `json:"workspace_proxies"`
|
||||
WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"`
|
||||
WorkspaceResources []WorkspaceResource `json:"workspace_resources"`
|
||||
WorkspaceAgentMemoryResourceMonitors []WorkspaceAgentMemoryResourceMonitor `json:"workspace_agent_memory_resource_monitors"`
|
||||
WorkspaceAgentVolumeResourceMonitors []WorkspaceAgentVolumeResourceMonitor `json:"workspace_agent_volume_resource_monitors"`
|
||||
WorkspaceModules []WorkspaceModule `json:"workspace_modules"`
|
||||
Workspaces []Workspace `json:"workspaces"`
|
||||
NetworkEvents []NetworkEvent `json:"network_events"`
|
||||
Organizations []Organization `json:"organizations"`
|
||||
TelemetryItems []TelemetryItem `json:"telemetry_items"`
|
||||
}
|
||||
|
||||
// Deployment contains information about the host running Coder.
|
||||
@@ -1232,6 +1276,22 @@ type WorkspaceAgentStat struct {
|
||||
SessionCountSSH int64 `json:"session_count_ssh"`
|
||||
}
|
||||
|
||||
type WorkspaceAgentMemoryResourceMonitor struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Threshold int32 `json:"threshold"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkspaceAgentVolumeResourceMonitor struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Threshold int32 `json:"threshold"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkspaceApp struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -112,6 +112,8 @@ func TestTelemetry(t *testing.T) {
|
||||
_, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
||||
|
||||
_ = dbgen.WorkspaceModule(t, db, database.WorkspaceModule{})
|
||||
_ = dbgen.WorkspaceAgentMemoryResourceMonitor(t, db, database.WorkspaceAgentMemoryResourceMonitor{})
|
||||
_ = dbgen.WorkspaceAgentVolumeResourceMonitor(t, db, database.WorkspaceAgentVolumeResourceMonitor{})
|
||||
|
||||
_, snapshot := collectSnapshot(t, db, nil)
|
||||
require.Len(t, snapshot.ProvisionerJobs, 1)
|
||||
@@ -133,6 +135,8 @@ func TestTelemetry(t *testing.T) {
|
||||
require.Len(t, snapshot.Organizations, 1)
|
||||
// We create one item manually above. The other is TelemetryEnabled, created by the snapshotter.
|
||||
require.Len(t, snapshot.TelemetryItems, 2)
|
||||
require.Len(t, snapshot.WorkspaceAgentMemoryResourceMonitors, 1)
|
||||
require.Len(t, snapshot.WorkspaceAgentVolumeResourceMonitors, 1)
|
||||
wsa := snapshot.WorkspaceAgents[0]
|
||||
require.Len(t, wsa.Subsystems, 2)
|
||||
require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystems[0])
|
||||
|
||||
+22
-2
@@ -922,7 +922,17 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if len(selectedMemberships) == 0 {
|
||||
httpmw.CustomRedirectToLogin(rw, r, redirect, "You aren't a member of the authorized Github organizations!", http.StatusUnauthorized)
|
||||
status := http.StatusUnauthorized
|
||||
msg := "You aren't a member of the authorized Github organizations!"
|
||||
if api.GithubOAuth2Config.DeviceFlowEnabled {
|
||||
// In the device flow, the error is rendered client-side.
|
||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||
Message: "Unauthorized",
|
||||
Detail: msg,
|
||||
})
|
||||
} else {
|
||||
httpmw.CustomRedirectToLogin(rw, r, redirect, msg, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -959,7 +969,17 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if allowedTeam == nil {
|
||||
httpmw.CustomRedirectToLogin(rw, r, redirect, fmt.Sprintf("You aren't a member of an authorized team in the %v Github organization(s)!", organizationNames), http.StatusUnauthorized)
|
||||
msg := fmt.Sprintf("You aren't a member of an authorized team in the %v Github organization(s)!", organizationNames)
|
||||
status := http.StatusUnauthorized
|
||||
if api.GithubOAuth2Config.DeviceFlowEnabled {
|
||||
// In the device flow, the error is rendered client-side.
|
||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||
Message: "Unauthorized",
|
||||
Detail: msg,
|
||||
})
|
||||
} else {
|
||||
httpmw.CustomRedirectToLogin(rw, r, redirect, msg, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +375,54 @@ func TestWorkspace(t *testing.T) {
|
||||
require.Error(t, err, "create workspace with archived version")
|
||||
require.ErrorContains(t, err, "Archived template versions cannot")
|
||||
})
|
||||
|
||||
t.Run("WorkspaceBan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
first := coderdtest.CreateFirstUser(t, owner)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, owner, first.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, owner, first.OrganizationID, version.ID)
|
||||
|
||||
goodClient, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
|
||||
|
||||
// When a user with workspace-creation-ban
|
||||
client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgWorkspaceCreationBan(first.OrganizationID))
|
||||
|
||||
// Ensure a similar user can create a workspace
|
||||
coderdtest.CreateWorkspace(t, goodClient, template.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
// Then: Cannot create a workspace
|
||||
_, err := client.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
TemplateVersionID: uuid.UUID{},
|
||||
Name: "random",
|
||||
})
|
||||
require.Error(t, err)
|
||||
var apiError *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiError)
|
||||
require.Equal(t, http.StatusForbidden, apiError.StatusCode())
|
||||
|
||||
// When: workspace-ban use has a workspace
|
||||
wrk, err := owner.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
TemplateVersionID: uuid.UUID{},
|
||||
Name: "random",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, wrk.LatestBuild.ID)
|
||||
|
||||
// Then: They cannot delete said workspace
|
||||
_, err = client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
ProvisionerState: []byte{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorAs(t, err, &apiError)
|
||||
require.Equal(t, http.StatusForbidden, apiError.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAutostart(t *testing.T) {
|
||||
|
||||
@@ -790,6 +790,15 @@ func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Obje
|
||||
return BuildError{http.StatusBadRequest, msg, xerrors.New(msg)}
|
||||
}
|
||||
if !authFunc(action, b.workspace) {
|
||||
if authFunc(policy.ActionRead, b.workspace) {
|
||||
// If the user can read the workspace, but not delete/create/update. Show
|
||||
// a more helpful error. They are allowed to know the workspace exists.
|
||||
return BuildError{
|
||||
Status: http.StatusForbidden,
|
||||
Message: fmt.Sprintf("You do not have permission to %s this workspace.", action),
|
||||
Wrapped: xerrors.New(httpapi.ResourceForbiddenResponse.Detail),
|
||||
}
|
||||
}
|
||||
// We use the same wording as the httpapi to avoid leaking the existence of the workspace
|
||||
return BuildError{http.StatusNotFound, httpapi.ResourceNotFoundResponse.Message, xerrors.New(httpapi.ResourceNotFoundResponse.Message)}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const (
|
||||
ActionRead RBACAction = "read"
|
||||
ActionReadPersonal RBACAction = "read_personal"
|
||||
ActionSSH RBACAction = "ssh"
|
||||
ActionUnassign RBACAction = "unassign"
|
||||
ActionUpdate RBACAction = "update"
|
||||
ActionUpdatePersonal RBACAction = "update_personal"
|
||||
ActionUse RBACAction = "use"
|
||||
@@ -62,8 +63,8 @@ const (
|
||||
var RBACResourceActions = map[RBACResource][]RBACAction{
|
||||
ResourceWildcard: {},
|
||||
ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate},
|
||||
ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign},
|
||||
ResourceAuditLog: {ActionCreate, ActionRead},
|
||||
ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceDebugInfo: {ActionRead},
|
||||
|
||||
@@ -8,9 +8,10 @@ const (
|
||||
RoleUserAdmin string = "user-admin"
|
||||
RoleAuditor string = "auditor"
|
||||
|
||||
RoleOrganizationAdmin string = "organization-admin"
|
||||
RoleOrganizationMember string = "organization-member"
|
||||
RoleOrganizationAuditor string = "organization-auditor"
|
||||
RoleOrganizationTemplateAdmin string = "organization-template-admin"
|
||||
RoleOrganizationUserAdmin string = "organization-user-admin"
|
||||
RoleOrganizationAdmin string = "organization-admin"
|
||||
RoleOrganizationMember string = "organization-member"
|
||||
RoleOrganizationAuditor string = "organization-auditor"
|
||||
RoleOrganizationTemplateAdmin string = "organization-template-admin"
|
||||
RoleOrganizationUserAdmin string = "organization-user-admin"
|
||||
RoleOrganizationWorkspaceCreationBan string = "organization-workspace-creation-ban"
|
||||
)
|
||||
|
||||
@@ -143,6 +143,12 @@ func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w
|
||||
// SSH pipes the SSH protocol over the returned net.Conn.
|
||||
// This connects to the built-in SSH server in the workspace agent.
|
||||
func (c *AgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
return c.SSHOnPort(ctx, AgentSSHPort)
|
||||
}
|
||||
|
||||
// SSHOnPort pipes the SSH protocol over the returned net.Conn.
|
||||
// This connects to the built-in SSH server in the workspace agent on the specified port.
|
||||
func (c *AgentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -150,17 +156,23 @@ func (c *AgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
c.Conn.SendConnectedTelemetry(c.agentAddress(), tailnet.TelemetryApplicationSSH)
|
||||
return c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentSSHPort))
|
||||
c.SendConnectedTelemetry(c.agentAddress(), tailnet.TelemetryApplicationSSH)
|
||||
return c.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), port))
|
||||
}
|
||||
|
||||
// SSHClient calls SSH to create a client that uses a weak cipher
|
||||
// to improve throughput.
|
||||
func (c *AgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
|
||||
return c.SSHClientOnPort(ctx, AgentSSHPort)
|
||||
}
|
||||
|
||||
// SSHClientOnPort calls SSH to create a client on a specific port
|
||||
// that uses a weak cipher to improve throughput.
|
||||
func (c *AgentConn) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
netConn, err := c.SSH(ctx)
|
||||
netConn, err := c.SSHOnPort(ctx, port)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh: %w", err)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ var ErrSkipClose = xerrors.New("skip tailnet close")
|
||||
|
||||
const (
|
||||
AgentSSHPort = tailnet.WorkspaceAgentSSHPort
|
||||
AgentStandardSSHPort = tailnet.WorkspaceAgentStandardSSHPort
|
||||
AgentReconnectingPTYPort = tailnet.WorkspaceAgentReconnectingPTYPort
|
||||
AgentSpeedtestPort = tailnet.WorkspaceAgentSpeedtestPort
|
||||
// AgentHTTPAPIServerPort serves a HTTP server with endpoints for e.g.
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# GitHub
|
||||
|
||||
## Default Configuration
|
||||
|
||||
By default, new Coder deployments use a Coder-managed GitHub app to authenticate
|
||||
users. We provide it for convenience, allowing you to experiment with Coder
|
||||
without setting up your own GitHub OAuth app. Once you authenticate with it, you
|
||||
grant Coder server read access to:
|
||||
|
||||
- Your GitHub user email
|
||||
- Your GitHub organization membership
|
||||
- Other metadata listed during the authentication flow
|
||||
|
||||
This access is necessary for the Coder server to complete the authentication
|
||||
process. To the best of our knowledge, Coder, the company, does not gain access
|
||||
to this data by administering the GitHub app.
|
||||
|
||||
For production deployments, we recommend configuring your own GitHub OAuth app
|
||||
as outlined below. The default is automatically disabled if you configure your
|
||||
own app or set:
|
||||
|
||||
```env
|
||||
CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE=false
|
||||
```
|
||||
|
||||
## Step 1: Configure the OAuth application in GitHub
|
||||
|
||||
First,
|
||||
@@ -82,3 +105,16 @@ helm upgrade <release-name> coder-v2/coder -n <namespace> -f values.yaml
|
||||
> We recommend requiring and auditing MFA usage for all users in your GitHub
|
||||
> organizations. This can be enforced from the organization settings page in the
|
||||
> "Authentication security" sidebar tab.
|
||||
|
||||
## Device Flow
|
||||
|
||||
Coder supports
|
||||
[device flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow)
|
||||
for GitHub OAuth. To enable it, set:
|
||||
|
||||
```env
|
||||
CODER_OAUTH2_GITHUB_DEVICE_FLOW=true
|
||||
```
|
||||
|
||||
This is optional. We recommend using the standard OAuth flow instead, as it is
|
||||
more convenient for end users.
|
||||
|
||||
@@ -101,6 +101,10 @@ coder:
|
||||
# postgres://coder:password@postgres:5432/coder?sslmode=disable
|
||||
name: coder-db-url
|
||||
key: url
|
||||
# For production deployments, we recommend configuring your own GitHub
|
||||
# OAuth2 provider and disabling the default one.
|
||||
- name: CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE
|
||||
value: "false"
|
||||
|
||||
# (Optional) For production deployments the access URL should be set.
|
||||
# If you're just trying Coder, access the dashboard via the service IP.
|
||||
|
||||
Generated
+5
@@ -173,6 +173,7 @@ Status Code **200**
|
||||
| `action` | `read` |
|
||||
| `action` | `read_personal` |
|
||||
| `action` | `ssh` |
|
||||
| `action` | `unassign` |
|
||||
| `action` | `update` |
|
||||
| `action` | `update_personal` |
|
||||
| `action` | `use` |
|
||||
@@ -335,6 +336,7 @@ Status Code **200**
|
||||
| `action` | `read` |
|
||||
| `action` | `read_personal` |
|
||||
| `action` | `ssh` |
|
||||
| `action` | `unassign` |
|
||||
| `action` | `update` |
|
||||
| `action` | `update_personal` |
|
||||
| `action` | `use` |
|
||||
@@ -497,6 +499,7 @@ Status Code **200**
|
||||
| `action` | `read` |
|
||||
| `action` | `read_personal` |
|
||||
| `action` | `ssh` |
|
||||
| `action` | `unassign` |
|
||||
| `action` | `update` |
|
||||
| `action` | `update_personal` |
|
||||
| `action` | `use` |
|
||||
@@ -628,6 +631,7 @@ Status Code **200**
|
||||
| `action` | `read` |
|
||||
| `action` | `read_personal` |
|
||||
| `action` | `ssh` |
|
||||
| `action` | `unassign` |
|
||||
| `action` | `update` |
|
||||
| `action` | `update_personal` |
|
||||
| `action` | `use` |
|
||||
@@ -891,6 +895,7 @@ Status Code **200**
|
||||
| `action` | `read` |
|
||||
| `action` | `read_personal` |
|
||||
| `action` | `ssh` |
|
||||
| `action` | `unassign` |
|
||||
| `action` | `update` |
|
||||
| `action` | `update_personal` |
|
||||
| `action` | `use` |
|
||||
|
||||
Generated
+1
@@ -5104,6 +5104,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
| `read` |
|
||||
| `read_personal` |
|
||||
| `ssh` |
|
||||
| `unassign` |
|
||||
| `update` |
|
||||
| `update_personal` |
|
||||
| `use` |
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
_ "github.com/coder/coder/v2/buildinfo/resources"
|
||||
entcli "github.com/coder/coder/v2/enterprise/cli"
|
||||
)
|
||||
|
||||
|
||||
@@ -127,8 +127,7 @@ func (api *API) putOrgRoles(rw http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
},
|
||||
ExcludeOrgRoles: false,
|
||||
// Linter requires all fields to be set. This field is not actually required.
|
||||
OrganizationID: organization.ID,
|
||||
OrganizationID: organization.ID,
|
||||
})
|
||||
// If it is a 404 (not found) error, ignore it.
|
||||
if err != nil && !httpapi.Is404Error(err) {
|
||||
|
||||
@@ -441,10 +441,11 @@ func TestListRoles(t *testing.T) {
|
||||
return member.ListOrganizationRoles(ctx, owner.OrganizationID)
|
||||
},
|
||||
ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{
|
||||
{Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -473,10 +474,11 @@ func TestListRoles(t *testing.T) {
|
||||
return orgAdmin.ListOrganizationRoles(ctx, owner.OrganizationID)
|
||||
},
|
||||
ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{
|
||||
{Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -505,10 +507,11 @@ func TestListRoles(t *testing.T) {
|
||||
return client.ListOrganizationRoles(ctx, owner.OrganizationID)
|
||||
},
|
||||
ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{
|
||||
{Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202
|
||||
|
||||
// There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here:
|
||||
// https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main
|
||||
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250129014916-8086c871eae6
|
||||
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250227024825-c9983534152a
|
||||
|
||||
// This is replaced to include
|
||||
// 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25
|
||||
|
||||
@@ -236,8 +236,8 @@ github.com/coder/serpent v0.10.0 h1:ofVk9FJXSek+SmL3yVE3GoArP83M+1tX+H7S4t8BSuM=
|
||||
github.com/coder/serpent v0.10.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q=
|
||||
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw=
|
||||
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ=
|
||||
github.com/coder/tailscale v1.1.1-0.20250129014916-8086c871eae6 h1:prDIwUcsSEKbs1Rc5FfdvtSfz2XGpW3FnJtWR+Mc7MY=
|
||||
github.com/coder/tailscale v1.1.1-0.20250129014916-8086c871eae6/go.mod h1:1ggFFdHTRjPRu9Yc1yA7nVHBYB50w9Ce7VIXNqcW6Ko=
|
||||
github.com/coder/tailscale v1.1.1-0.20250227024825-c9983534152a h1:18TQ03KlYrkW8hOohTQaDnlmkY1H9pDPGbZwOnUUmm8=
|
||||
github.com/coder/tailscale v1.1.1-0.20250227024825-c9983534152a/go.mod h1:1ggFFdHTRjPRu9Yc1yA7nVHBYB50w9Ce7VIXNqcW6Ko=
|
||||
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0=
|
||||
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI=
|
||||
github.com/coder/terraform-provider-coder/v2 v2.1.3 h1:zB7ObGsiOGBHcJUUMmcSauEPlTWRIYmMYieF05LxHSc=
|
||||
|
||||
@@ -47,6 +47,10 @@ coder:
|
||||
# This env enables the Prometheus metrics endpoint.
|
||||
- name: CODER_PROMETHEUS_ADDRESS
|
||||
value: "0.0.0.0:2112"
|
||||
# For production deployments, we recommend configuring your own GitHub
|
||||
# OAuth2 provider and disabling the default one.
|
||||
- name: CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE
|
||||
value: "false"
|
||||
tls:
|
||||
secretNames:
|
||||
- my-tls-secret-name
|
||||
|
||||
+108
-6
@@ -36,17 +36,19 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
version=""
|
||||
os="${GOOS:-linux}"
|
||||
arch="${GOARCH:-amd64}"
|
||||
output_path=""
|
||||
slim="${CODER_SLIM_BUILD:-0}"
|
||||
agpl="${CODER_BUILD_AGPL:-0}"
|
||||
sign_darwin="${CODER_SIGN_DARWIN:-0}"
|
||||
sign_windows="${CODER_SIGN_WINDOWS:-0}"
|
||||
bin_ident="com.coder.cli"
|
||||
output_path=""
|
||||
agpl="${CODER_BUILD_AGPL:-0}"
|
||||
boringcrypto=${CODER_BUILD_BORINGCRYPTO:-0}
|
||||
debug=0
|
||||
dylib=0
|
||||
windows_resources="${CODER_WINDOWS_RESOURCES:-0}"
|
||||
debug=0
|
||||
|
||||
args="$(getopt -o "" -l version:,os:,arch:,output:,slim,agpl,sign-darwin,boringcrypto,dylib,debug -- "$@")"
|
||||
bin_ident="com.coder.cli"
|
||||
|
||||
args="$(getopt -o "" -l version:,os:,arch:,output:,slim,agpl,sign-darwin,sign-windows,boringcrypto,dylib,windows-resources,debug -- "$@")"
|
||||
eval set -- "$args"
|
||||
while true; do
|
||||
case "$1" in
|
||||
@@ -79,6 +81,10 @@ while true; do
|
||||
sign_darwin=1
|
||||
shift
|
||||
;;
|
||||
--sign-windows)
|
||||
sign_windows=1
|
||||
shift
|
||||
;;
|
||||
--boringcrypto)
|
||||
boringcrypto=1
|
||||
shift
|
||||
@@ -87,6 +93,10 @@ while true; do
|
||||
dylib=1
|
||||
shift
|
||||
;;
|
||||
--windows-resources)
|
||||
windows_resources=1
|
||||
shift
|
||||
;;
|
||||
--debug)
|
||||
debug=1
|
||||
shift
|
||||
@@ -115,11 +125,13 @@ if [[ "$sign_darwin" == 1 ]]; then
|
||||
dependencies rcodesign
|
||||
requiredenvs AC_CERTIFICATE_FILE AC_CERTIFICATE_PASSWORD_FILE
|
||||
fi
|
||||
|
||||
if [[ "$sign_windows" == 1 ]]; then
|
||||
dependencies java
|
||||
requiredenvs JSIGN_PATH EV_KEYSTORE EV_KEY EV_CERTIFICATE_PATH EV_TSA_URL GCLOUD_ACCESS_TOKEN
|
||||
fi
|
||||
if [[ "$windows_resources" == 1 ]]; then
|
||||
dependencies go-winres
|
||||
fi
|
||||
|
||||
ldflags=(
|
||||
-X "'github.com/coder/coder/v2/buildinfo.tag=$version'"
|
||||
@@ -204,10 +216,100 @@ if [[ "$boringcrypto" == 1 ]]; then
|
||||
goexp="boringcrypto"
|
||||
fi
|
||||
|
||||
# On Windows, we use go-winres to embed the resources into the binary.
|
||||
if [[ "$windows_resources" == 1 ]] && [[ "$os" == "windows" ]]; then
|
||||
# Convert the version to a format that Windows understands.
|
||||
# Remove any trailing data after a "+" or "-".
|
||||
version_windows=$version
|
||||
version_windows="${version_windows%+*}"
|
||||
version_windows="${version_windows%-*}"
|
||||
# If there wasn't any extra data, add a .0 to the version. Otherwise, add
|
||||
# a .1 to the version to signify that this is not a release build so it can
|
||||
# be distinguished from a release build.
|
||||
non_release_build=0
|
||||
if [[ "$version_windows" == "$version" ]]; then
|
||||
version_windows+=".0"
|
||||
else
|
||||
version_windows+=".1"
|
||||
non_release_build=1
|
||||
fi
|
||||
|
||||
if [[ ! "$version_windows" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-1]$ ]]; then
|
||||
error "Computed invalid windows version format: $version_windows"
|
||||
fi
|
||||
|
||||
# File description changes based on slimness, AGPL status, and architecture.
|
||||
file_description="Coder"
|
||||
if [[ "$agpl" == 1 ]]; then
|
||||
file_description+=" AGPL"
|
||||
fi
|
||||
if [[ "$slim" == 1 ]]; then
|
||||
file_description+=" CLI"
|
||||
fi
|
||||
if [[ "$non_release_build" == 1 ]]; then
|
||||
file_description+=" (development build)"
|
||||
fi
|
||||
|
||||
# Because this writes to a file with the OS and arch in the filename, we
|
||||
# don't support concurrent builds for the same OS and arch (irregardless of
|
||||
# slimness or AGPL status).
|
||||
#
|
||||
# This is fine since we only embed resources during dogfood and release
|
||||
# builds, which use make (which will build all slim targets in parallel,
|
||||
# then all non-slim targets in parallel).
|
||||
expected_rsrc_file="./buildinfo/resources/resources_windows_${arch}.syso"
|
||||
if [[ -f "$expected_rsrc_file" ]]; then
|
||||
rm "$expected_rsrc_file"
|
||||
fi
|
||||
touch "$expected_rsrc_file"
|
||||
|
||||
pushd ./buildinfo/resources
|
||||
GOARCH="$arch" go-winres simply \
|
||||
--arch "$arch" \
|
||||
--out "resources" \
|
||||
--product-version "$version_windows" \
|
||||
--file-version "$version_windows" \
|
||||
--manifest "cli" \
|
||||
--file-description "$file_description" \
|
||||
--product-name "Coder" \
|
||||
--copyright "Copyright $(date +%Y) Coder Technologies Inc." \
|
||||
--original-filename "coder.exe" \
|
||||
--icon ../../scripts/win-installer/coder.ico
|
||||
popd
|
||||
|
||||
if [[ ! -f "$expected_rsrc_file" ]]; then
|
||||
error "Failed to generate $expected_rsrc_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
set +e
|
||||
GOEXPERIMENT="$goexp" CGO_ENABLED="$cgo" GOOS="$os" GOARCH="$arch" GOARM="$arm_version" \
|
||||
go build \
|
||||
"${build_args[@]}" \
|
||||
"$cmd_path" 1>&2
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
# Clean up the resources file if it was generated.
|
||||
if [[ "$windows_resources" == 1 ]] && [[ "$os" == "windows" ]]; then
|
||||
rm "$expected_rsrc_file"
|
||||
fi
|
||||
|
||||
if [[ "$exit_code" != 0 ]]; then
|
||||
exit "$exit_code"
|
||||
fi
|
||||
|
||||
# If we did embed resources, verify that they were included.
|
||||
if [[ "$windows_resources" == 1 ]] && [[ "$os" == "windows" ]]; then
|
||||
winres_dir=$(mktemp -d)
|
||||
if ! go-winres extract --dir "$winres_dir" "$output_path" 1>&2; then
|
||||
rm -rf "$winres_dir"
|
||||
error "Compiled binary does not contain embedded resources"
|
||||
fi
|
||||
# If go-winres didn't return an error, it means it did find embedded
|
||||
# resources.
|
||||
rm -rf "$winres_dir"
|
||||
fi
|
||||
|
||||
if [[ "$sign_darwin" == 1 ]] && [[ "$os" == "darwin" ]]; then
|
||||
execrelative ./sign_darwin.sh "$output_path" "$bin_ident" 1>&2
|
||||
|
||||
+29
-3
@@ -3,8 +3,8 @@ import { expect } from "@playwright/test";
|
||||
import { API, type DeploymentConfig } from "api/api";
|
||||
import type { SerpentOption } from "api/typesGenerated";
|
||||
import { formatDuration, intervalToDuration } from "date-fns";
|
||||
import { coderPort } from "./constants";
|
||||
import { findSessionToken, randomName } from "./helpers";
|
||||
import { coderPort, defaultPassword } from "./constants";
|
||||
import { type LoginOptions, findSessionToken, randomName } from "./helpers";
|
||||
|
||||
let currentOrgId: string;
|
||||
|
||||
@@ -29,14 +29,40 @@ export const createUser = async (...orgIds: string[]) => {
|
||||
email: `${name}@coder.com`,
|
||||
username: name,
|
||||
name: name,
|
||||
password: "s3cure&password!",
|
||||
password: defaultPassword,
|
||||
login_type: "password",
|
||||
organization_ids: orgIds,
|
||||
user_status: null,
|
||||
});
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const createOrganizationMember = async (
|
||||
orgRoles: Record<string, string[]>,
|
||||
): Promise<LoginOptions> => {
|
||||
const name = randomName();
|
||||
const user = await API.createUser({
|
||||
email: `${name}@coder.com`,
|
||||
username: name,
|
||||
name: name,
|
||||
password: defaultPassword,
|
||||
login_type: "password",
|
||||
organization_ids: Object.keys(orgRoles),
|
||||
user_status: null,
|
||||
});
|
||||
|
||||
for (const [org, roles] of Object.entries(orgRoles)) {
|
||||
API.updateOrganizationMemberRoles(org, user.id, roles);
|
||||
}
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: defaultPassword,
|
||||
};
|
||||
};
|
||||
|
||||
export const createGroup = async (orgId: string) => {
|
||||
const name = randomName();
|
||||
const group = await API.createGroup(orgId, {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const coderdPProfPort = 6062;
|
||||
|
||||
// The name of the organization that should be used by default when needed.
|
||||
export const defaultOrganizationName = "coder";
|
||||
export const defaultOrganizationId = "00000000-0000-0000-0000-000000000000";
|
||||
export const defaultPassword = "SomeSecurePassword!";
|
||||
|
||||
// Credentials for users
|
||||
@@ -30,6 +31,12 @@ export const users = {
|
||||
email: "templateadmin@coder.com",
|
||||
roles: ["Template Admin"],
|
||||
},
|
||||
userAdmin: {
|
||||
username: "user-admin",
|
||||
password: defaultPassword,
|
||||
email: "useradmin@coder.com",
|
||||
roles: ["User Admin"],
|
||||
},
|
||||
auditor: {
|
||||
username: "auditor",
|
||||
password: defaultPassword,
|
||||
|
||||
+28
-1
@@ -61,7 +61,7 @@ export function requireTerraformProvisioner() {
|
||||
test.skip(!requireTerraformTests);
|
||||
}
|
||||
|
||||
type LoginOptions = {
|
||||
export type LoginOptions = {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -1127,3 +1127,30 @@ export async function createOrganization(page: Page): Promise<{
|
||||
|
||||
return { name, displayName, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param organization organization name
|
||||
* @param user user email or username
|
||||
*/
|
||||
export async function addUserToOrganization(
|
||||
page: Page,
|
||||
organization: string,
|
||||
user: string,
|
||||
roles: string[] = [],
|
||||
): Promise<void> {
|
||||
await page.goto(`/organizations/${organization}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await page.getByPlaceholder("User email or username").fill(user);
|
||||
await page.getByRole("option", { name: user }).click();
|
||||
await page.getByRole("button", { name: "Add user" }).click();
|
||||
const addedRow = page.locator("tr", { hasText: user });
|
||||
await expect(addedRow).toBeVisible();
|
||||
|
||||
await addedRow.getByLabel("Edit user roles").click();
|
||||
for (const role of roles) {
|
||||
await page.getByText(role).click();
|
||||
}
|
||||
await page.mouse.click(10, 10); // close the popover by clicking outside of it
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
createGroup,
|
||||
createOrganization,
|
||||
createOrganizationMember,
|
||||
createUser,
|
||||
setupApiCalls,
|
||||
} from "../api";
|
||||
import { defaultOrganizationName } from "../constants";
|
||||
import { defaultOrganizationId, defaultOrganizationName } from "../constants";
|
||||
import { expectUrl } from "../expectUrl";
|
||||
import { login, randomName, requiresLicense } from "../helpers";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
@@ -32,6 +33,11 @@ test("create group", async ({ page }) => {
|
||||
|
||||
// Create a new organization
|
||||
const org = await createOrganization();
|
||||
const orgUserAdmin = await createOrganizationMember({
|
||||
[org.id]: ["organization-user-admin"],
|
||||
});
|
||||
|
||||
await login(page, orgUserAdmin);
|
||||
await page.goto(`/organizations/${org.name}`);
|
||||
|
||||
// Navigate to groups page
|
||||
@@ -64,8 +70,7 @@ test("create group", async ({ page }) => {
|
||||
await expect(addedRow).toBeVisible();
|
||||
|
||||
// Ensure we can't add a user who isn't in the org
|
||||
const otherOrg = await createOrganization();
|
||||
const personToReject = await createUser(otherOrg.id);
|
||||
const personToReject = await createUser(defaultOrganizationId);
|
||||
await page
|
||||
.getByPlaceholder("User email or username")
|
||||
.fill(personToReject.email);
|
||||
@@ -93,8 +98,12 @@ test("change quota settings", async ({ page }) => {
|
||||
// Create a new organization and group
|
||||
const org = await createOrganization();
|
||||
const group = await createGroup(org.id);
|
||||
const orgUserAdmin = await createOrganizationMember({
|
||||
[org.id]: ["organization-user-admin"],
|
||||
});
|
||||
|
||||
// Go to settings
|
||||
await login(page, orgUserAdmin);
|
||||
await page.goto(`/organizations/${org.name}/groups/${group.name}`);
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
expectUrl(page).toHavePathName(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { setupApiCalls } from "../api";
|
||||
import {
|
||||
addUserToOrganization,
|
||||
createOrganization,
|
||||
createUser,
|
||||
login,
|
||||
@@ -18,7 +19,7 @@ test("add and remove organization member", async ({ page }) => {
|
||||
requiresLicense();
|
||||
|
||||
// Create a new organization
|
||||
const { displayName } = await createOrganization(page);
|
||||
const { name: orgName, displayName } = await createOrganization(page);
|
||||
|
||||
// Navigate to members page
|
||||
await page.getByRole("link", { name: "Members" }).click();
|
||||
@@ -26,17 +27,14 @@ test("add and remove organization member", async ({ page }) => {
|
||||
|
||||
// Add a user to the org
|
||||
const personToAdd = await createUser(page);
|
||||
await page.getByPlaceholder("User email or username").fill(personToAdd.email);
|
||||
await page.getByRole("option", { name: personToAdd.email }).click();
|
||||
await page.getByRole("button", { name: "Add user" }).click();
|
||||
const addedRow = page.locator("tr", { hasText: personToAdd.email });
|
||||
await expect(addedRow).toBeVisible();
|
||||
// This must be done as an admin, because you can't assign a role that has more
|
||||
// permissions than you, even if you have the ability to assign roles.
|
||||
await addUserToOrganization(page, orgName, personToAdd.email, [
|
||||
"Organization User Admin",
|
||||
"Organization Template Admin",
|
||||
]);
|
||||
|
||||
// Give them a role
|
||||
await addedRow.getByLabel("Edit user roles").click();
|
||||
await page.getByText("Organization User Admin").click();
|
||||
await page.getByText("Organization Template Admin").click();
|
||||
await page.mouse.click(10, 10); // close the popover by clicking outside of it
|
||||
const addedRow = page.locator("tr", { hasText: personToAdd.email });
|
||||
await expect(addedRow.getByText("Organization User Admin")).toBeVisible();
|
||||
await expect(addedRow.getByText("+1 more")).toBeVisible();
|
||||
|
||||
|
||||
@@ -15,18 +15,17 @@ export const RBACResourceActions: Partial<
|
||||
update: "update an api key, eg expires",
|
||||
},
|
||||
assign_org_role: {
|
||||
assign: "ability to assign org scoped roles",
|
||||
create: "ability to create/delete custom roles within an organization",
|
||||
delete: "ability to delete org scoped roles",
|
||||
read: "view what roles are assignable",
|
||||
update: "ability to edit custom roles within an organization",
|
||||
assign: "assign org scoped roles",
|
||||
create: "create/delete custom roles within an organization",
|
||||
delete: "delete roles within an organization",
|
||||
read: "view what roles are assignable within an organization",
|
||||
unassign: "unassign org scoped roles",
|
||||
update: "edit custom roles within an organization",
|
||||
},
|
||||
assign_role: {
|
||||
assign: "ability to assign roles",
|
||||
create: "ability to create/delete/edit custom roles",
|
||||
delete: "ability to unassign roles",
|
||||
assign: "assign user roles",
|
||||
read: "view what roles are assignable",
|
||||
update: "ability to edit custom roles",
|
||||
unassign: "unassign user roles",
|
||||
},
|
||||
audit_log: {
|
||||
create: "create new audit log entries",
|
||||
|
||||
Generated
+6
@@ -1856,6 +1856,7 @@ export type RBACAction =
|
||||
| "read"
|
||||
| "read_personal"
|
||||
| "ssh"
|
||||
| "unassign"
|
||||
| "update"
|
||||
| "update_personal"
|
||||
| "use"
|
||||
@@ -1871,6 +1872,7 @@ export const RBACActions: RBACAction[] = [
|
||||
"read",
|
||||
"read_personal",
|
||||
"ssh",
|
||||
"unassign",
|
||||
"update",
|
||||
"update_personal",
|
||||
"use",
|
||||
@@ -2101,6 +2103,10 @@ export const RoleOrganizationTemplateAdmin = "organization-template-admin";
|
||||
// From codersdk/rbacroles.go
|
||||
export const RoleOrganizationUserAdmin = "organization-user-admin";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
export const RoleOrganizationWorkspaceCreationBan =
|
||||
"organization-workspace-creation-ban";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
export const RoleOwner = "owner";
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ const avatarVariants = cva(
|
||||
export type AvatarProps = AvatarPrimitive.AvatarProps &
|
||||
VariantProps<typeof avatarVariants> & {
|
||||
src?: string;
|
||||
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "../Button/Button";
|
||||
import { CollapsibleSummary } from "./CollapsibleSummary";
|
||||
|
||||
const meta: Meta<typeof CollapsibleSummary> = {
|
||||
title: "components/CollapsibleSummary",
|
||||
component: CollapsibleSummary,
|
||||
args: {
|
||||
label: "Advanced options",
|
||||
children: (
|
||||
<>
|
||||
<div className="p-2 border border-border rounded-md border-solid">
|
||||
Option 1
|
||||
</div>
|
||||
<div className="p-2 border border-border rounded-md border-solid">
|
||||
Option 2
|
||||
</div>
|
||||
<div className="p-2 border border-border rounded-md border-solid">
|
||||
Option 3
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CollapsibleSummary>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const DefaultOpen: Story = {
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumSize: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallSize: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomClassName: Story = {
|
||||
args: {
|
||||
className: "text-blue-500 font-bold",
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyChildren: Story = {
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
children: (
|
||||
<>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={`option-${i + 1}`}
|
||||
className="p-2 border border-border rounded-md border-solid"
|
||||
>
|
||||
Option {i + 1}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const NestedCollapsible: Story = {
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
children: (
|
||||
<>
|
||||
<div className="p-2 border border-border rounded-md border-solid">
|
||||
Option 1
|
||||
</div>
|
||||
<CollapsibleSummary label="Nested options" size="sm">
|
||||
<div className="p-2 border border-border rounded-md border-solid">
|
||||
Nested Option 1
|
||||
</div>
|
||||
<div className="p-2 border border-border rounded-md border-solid">
|
||||
Nested Option 2
|
||||
</div>
|
||||
</CollapsibleSummary>
|
||||
<div className="p-2 border border-border rounded-md border-solid">
|
||||
Option 3
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ComplexContent: Story = {
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
children: (
|
||||
<div className="p-4 border border-border rounded-md bg-surface-secondary">
|
||||
<h3 className="text-lg font-bold mb-2">Complex Content</h3>
|
||||
<p className="mb-4">
|
||||
This is a more complex content example with various elements.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button>Action 1</Button>
|
||||
<Button>Action 2</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const LongLabel: Story = {
|
||||
args: {
|
||||
label:
|
||||
"This is a very long label that might wrap or cause layout issues if not handled properly",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const collapsibleSummaryVariants = cva(
|
||||
`flex items-center gap-1 p-0 bg-transparent border-0 text-inherit cursor-pointer
|
||||
transition-colors text-content-secondary hover:text-content-primary font-medium
|
||||
whitespace-nowrap`,
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
md: "text-sm",
|
||||
sm: "text-xs",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface CollapsibleSummaryProps
|
||||
extends VariantProps<typeof collapsibleSummaryVariants> {
|
||||
/**
|
||||
* The label to display for the collapsible section
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* The content to show when expanded
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Whether the section is initially expanded
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* Optional className for the button
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The size of the component
|
||||
*/
|
||||
size?: "md" | "sm";
|
||||
}
|
||||
|
||||
export const CollapsibleSummary: FC<CollapsibleSummaryProps> = ({
|
||||
label,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
size,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
className={cn(
|
||||
collapsibleSummaryVariants({ size }),
|
||||
isOpen && "text-content-primary",
|
||||
className,
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center transition-transform duration-200",
|
||||
isOpen ? "rotate-90" : "rotate-0",
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"p-0.5",
|
||||
size === "sm" ? "size-icon-xs" : "size-icon-sm",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="sr-only">
|
||||
({isOpen ? "Hide" : "Show"}) {label}
|
||||
</span>
|
||||
<span className="[&:first-letter]:uppercase">{label}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && <div className="flex flex-col gap-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -94,11 +94,6 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
|
||||
IdP Organization Sync
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavItem href="/deployment/provisioners">
|
||||
Provisioners
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{!hasPremiumLicense && (
|
||||
<SidebarNavItem href="/deployment/premium">Premium</SidebarNavItem>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { TimelineEntry } from "components/Timeline/TimelineEntry";
|
||||
import { NetworkIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import type { ThemeRole } from "theme/roles";
|
||||
@@ -101,10 +102,20 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
|
||||
css={styles.auditLogHeaderInfo}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" css={styles.fullWidth}>
|
||||
<Avatar
|
||||
fallback={auditLog.user?.username ?? "?"}
|
||||
src={auditLog.user?.avatar_url}
|
||||
/>
|
||||
{/*
|
||||
* Session logs don't have an associated user to the log,
|
||||
* so when it happens we display a default icon to represent non user actions
|
||||
*/}
|
||||
{auditLog.user ? (
|
||||
<Avatar
|
||||
fallback={auditLog.user.username}
|
||||
src={auditLog.user.avatar_url}
|
||||
/>
|
||||
) : (
|
||||
<Avatar>
|
||||
<NetworkIcon className="h-full w-full p-1" />
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
|
||||
@@ -68,14 +68,6 @@ const AuditPage: FC = () => {
|
||||
}),
|
||||
});
|
||||
|
||||
if (auditsQuery.error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorAlert error={auditsQuery.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
|
||||
@@ -57,7 +57,8 @@ export const CustomRolesPage: FC = () => {
|
||||
<RequirePermission
|
||||
isFeatureVisible={
|
||||
organizationPermissions.assignOrgRoles ||
|
||||
organizationPermissions.createOrgRoles
|
||||
organizationPermissions.createOrgRoles ||
|
||||
organizationPermissions.viewOrgRoles
|
||||
}
|
||||
>
|
||||
<Helmet>
|
||||
|
||||
@@ -72,6 +72,7 @@ const OrganizationMembersPage: FC = () => {
|
||||
<OrganizationMembersPageView
|
||||
allAvailableRoles={organizationRolesQuery.data}
|
||||
canEditMembers={organizationPermissions.editMembers}
|
||||
canViewMembers={organizationPermissions.viewMembers}
|
||||
error={
|
||||
membersQuery.error ??
|
||||
organizationRolesQuery.error ??
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell";
|
||||
import { type FC, useState } from "react";
|
||||
import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip";
|
||||
@@ -36,6 +37,7 @@ import { UserRoleCell } from "./UserTable/UserRoleCell";
|
||||
interface OrganizationMembersPageViewProps {
|
||||
allAvailableRoles: readonly SlimRole[] | undefined;
|
||||
canEditMembers: boolean;
|
||||
canViewMembers: boolean;
|
||||
error: unknown;
|
||||
isAddingMember: boolean;
|
||||
isUpdatingMemberRoles: boolean;
|
||||
@@ -58,6 +60,7 @@ export const OrganizationMembersPageView: FC<
|
||||
> = ({
|
||||
allAvailableRoles,
|
||||
canEditMembers,
|
||||
canViewMembers,
|
||||
error,
|
||||
isAddingMember,
|
||||
isUpdatingMemberRoles,
|
||||
@@ -70,7 +73,7 @@ export const OrganizationMembersPageView: FC<
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Members" />
|
||||
<Stack>
|
||||
<div className="flex flex-col gap-4">
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
|
||||
{canEditMembers && (
|
||||
@@ -80,6 +83,15 @@ export const OrganizationMembersPageView: FC<
|
||||
/>
|
||||
)}
|
||||
|
||||
{!canViewMembers && (
|
||||
<div className="flex flex-row text-content-warning gap-2 items-center text-sm font-medium">
|
||||
<TriangleAlert className="size-icon-sm" />
|
||||
<p>
|
||||
You do not have permission to view members other than yourself.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -154,7 +166,7 @@ export const OrganizationMembersPageView: FC<
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MockOwnerRole,
|
||||
MockSiteRoles,
|
||||
MockUserAdminRole,
|
||||
MockWorkspaceCreationBanRole,
|
||||
} from "testHelpers/entities";
|
||||
import { withDesktopViewport } from "testHelpers/storybook";
|
||||
import { EditRolesButton } from "./EditRolesButton";
|
||||
@@ -41,3 +42,14 @@ export const Loading: Story = {
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
},
|
||||
};
|
||||
|
||||
export const AdvancedOpen: Story = {
|
||||
args: {
|
||||
selectedRoleNames: new Set([MockWorkspaceCreationBanRole.name]),
|
||||
roles: MockSiteRoles,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import Checkbox from "@mui/material/Checkbox";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import type { SlimRole } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { CollapsibleSummary } from "components/CollapsibleSummary/CollapsibleSummary";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipContent,
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/deprecated/Popover/Popover";
|
||||
import type { FC } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const roleDescriptions: Record<string, string> = {
|
||||
owner:
|
||||
@@ -57,7 +60,7 @@ const Option: FC<OptionProps> = ({
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<strong>{name}</strong>
|
||||
<strong className="text-sm">{name}</strong>
|
||||
<span className="text-xs text-content-secondary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,6 +94,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
||||
|
||||
onChange([...selectedRoleNames, roleName]);
|
||||
};
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
|
||||
const canSetRoles =
|
||||
userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync);
|
||||
@@ -109,6 +113,20 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const filteredRoles = roles.filter(
|
||||
(role) => role.name !== "organization-workspace-creation-ban",
|
||||
);
|
||||
const advancedRoles = roles.filter(
|
||||
(role) => role.name === "organization-workspace-creation-ban",
|
||||
);
|
||||
|
||||
// make sure the advanced roles are always visible if the user has one of these roles
|
||||
useEffect(() => {
|
||||
if (selectedRoleNames.has("organization-workspace-creation-ban")) {
|
||||
setIsAdvancedOpen(true);
|
||||
}
|
||||
}, [selectedRoleNames]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
@@ -124,14 +142,14 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
||||
</Tooltip>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-80" disablePortal={false}>
|
||||
<PopoverContent className="w-96" disablePortal={false}>
|
||||
<fieldset
|
||||
className="border-0 m-0 p-0 disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
title="Available roles"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
{roles.map((role) => (
|
||||
<div className="flex flex-col gap-4 p-6 w-96">
|
||||
{filteredRoles.map((role) => (
|
||||
<Option
|
||||
key={role.name}
|
||||
onChange={handleChange}
|
||||
@@ -141,6 +159,20 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
||||
description={roleDescriptions[role.name] ?? ""}
|
||||
/>
|
||||
))}
|
||||
{advancedRoles.length > 0 && (
|
||||
<CollapsibleSummary label="advanced" defaultOpen={isAdvancedOpen}>
|
||||
{advancedRoles.map((role) => (
|
||||
<Option
|
||||
key={role.name}
|
||||
onChange={handleChange}
|
||||
isChecked={selectedRoleNames.has(role.name)}
|
||||
value={role.name}
|
||||
name={role.display_name || role.name}
|
||||
description={roleDescriptions[role.name] ?? ""}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSummary>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="p-6 border-t-1 border-solid border-border text-sm">
|
||||
|
||||
@@ -81,6 +81,7 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
|
||||
useEffect(() => {
|
||||
const updateProgress = () => {
|
||||
if (
|
||||
job === undefined ||
|
||||
job.status !== "running" ||
|
||||
transitionStats.P50 === undefined ||
|
||||
transitionStats.P95 === undefined ||
|
||||
|
||||
@@ -296,6 +296,15 @@ export const MockAuditorRole: TypesGen.Role = {
|
||||
organization_id: "",
|
||||
};
|
||||
|
||||
export const MockWorkspaceCreationBanRole: TypesGen.Role = {
|
||||
name: "organization-workspace-creation-ban",
|
||||
display_name: "Organization Workspace Creation Ban",
|
||||
site_permissions: [],
|
||||
organization_permissions: [],
|
||||
user_permissions: [],
|
||||
organization_id: "",
|
||||
};
|
||||
|
||||
export const MockMemberRole: TypesGen.SlimRole = {
|
||||
name: "member",
|
||||
display_name: "Member",
|
||||
@@ -459,10 +468,15 @@ export function assignableRole(
|
||||
};
|
||||
}
|
||||
|
||||
export const MockSiteRoles = [MockUserAdminRole, MockAuditorRole];
|
||||
export const MockSiteRoles = [
|
||||
MockUserAdminRole,
|
||||
MockAuditorRole,
|
||||
MockWorkspaceCreationBanRole,
|
||||
];
|
||||
export const MockAssignableSiteRoles = [
|
||||
assignableRole(MockUserAdminRole, true),
|
||||
assignableRole(MockAuditorRole, true),
|
||||
assignableRole(MockWorkspaceCreationBanRole, true),
|
||||
];
|
||||
|
||||
export const MockMemberPermissions = {
|
||||
|
||||
+2
-1
@@ -52,6 +52,7 @@ const (
|
||||
WorkspaceAgentSSHPort = 1
|
||||
WorkspaceAgentReconnectingPTYPort = 2
|
||||
WorkspaceAgentSpeedtestPort = 3
|
||||
WorkspaceAgentStandardSSHPort = 22
|
||||
)
|
||||
|
||||
// EnvMagicsockDebugLogging enables super-verbose logging for the magicsock
|
||||
@@ -745,7 +746,7 @@ func (c *Conn) forwardTCP(src, dst netip.AddrPort) (handler func(net.Conn), opts
|
||||
return nil, nil, false
|
||||
}
|
||||
// See: https://github.com/tailscale/tailscale/blob/c7cea825aea39a00aca71ea02bab7266afc03e7c/wgengine/netstack/netstack.go#L888
|
||||
if dst.Port() == WorkspaceAgentSSHPort || dst.Port() == 22 {
|
||||
if dst.Port() == WorkspaceAgentSSHPort || dst.Port() == WorkspaceAgentStandardSSHPort {
|
||||
opt := tcpip.KeepaliveIdleOption(72 * time.Hour)
|
||||
opts = append(opts, &opt)
|
||||
}
|
||||
|
||||
+31
-3
@@ -25,7 +25,12 @@ import (
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
const tunName = "Coder"
|
||||
const (
|
||||
tunName = "Coder"
|
||||
tunGUID = "{0ed1515d-04a4-4c46-abae-11ad07cf0e6d}"
|
||||
|
||||
wintunDLL = "wintun.dll"
|
||||
)
|
||||
|
||||
func GetNetworkingStack(t *Tunnel, _ *StartRequest, logger slog.Logger) (NetworkStack, error) {
|
||||
// Initialize COM process-wide so Tailscale can make calls to the windows
|
||||
@@ -44,12 +49,35 @@ func GetNetworkingStack(t *Tunnel, _ *StartRequest, logger slog.Logger) (Network
|
||||
|
||||
// Set the name and GUID for the TUN interface.
|
||||
tun.WintunTunnelType = tunName
|
||||
guid, err := windows.GUIDFromString("{0ed1515d-04a4-4c46-abae-11ad07cf0e6d}")
|
||||
guid, err := windows.GUIDFromString(tunGUID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return NetworkStack{}, xerrors.Errorf("could not parse GUID %q: %w", tunGUID, err)
|
||||
}
|
||||
tun.WintunStaticRequestedGUID = &guid
|
||||
|
||||
// Ensure wintun.dll is available, and fail early if it's not to avoid
|
||||
// hanging for 5 minutes in tstunNewWithWindowsRetries.
|
||||
//
|
||||
// First, we call wintun.Version() to make the wintun package attempt to
|
||||
// load wintun.dll. This allows the wintun package to set the logging
|
||||
// callback in the DLL before we load it ourselves.
|
||||
_ = wintun.Version()
|
||||
|
||||
// Then, we try to load wintun.dll ourselves so we get a better error
|
||||
// message if there was a problem. This call matches the wintun package, so
|
||||
// we're loading it in the same way.
|
||||
//
|
||||
// Note: this leaks the handle to wintun.dll, but since it's already loaded
|
||||
// it wouldn't be freed anyways.
|
||||
const (
|
||||
LOAD_LIBRARY_SEARCH_APPLICATION_DIR = 0x00000200
|
||||
LOAD_LIBRARY_SEARCH_SYSTEM32 = 0x00000800
|
||||
)
|
||||
_, err = windows.LoadLibraryEx(wintunDLL, 0, LOAD_LIBRARY_SEARCH_APPLICATION_DIR|LOAD_LIBRARY_SEARCH_SYSTEM32)
|
||||
if err != nil {
|
||||
return NetworkStack{}, xerrors.Errorf("could not load %q, it should be in the same directory as the executable (in Coder Desktop, this should have been installed automatically): %w", wintunDLL, err)
|
||||
}
|
||||
|
||||
tunDev, tunName, err := tstunNewWithWindowsRetries(tailnet.Logger(logger.Named("net.tun.device")), tunName)
|
||||
if err != nil {
|
||||
return NetworkStack{}, xerrors.Errorf("create tun device: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user