feat: add organization scope for shared ports (#18314)

This commit is contained in:
ケイラ
2025-06-16 16:15:59 -06:00
committed by GitHub
parent eff2174198
commit 5df70a613d
30 changed files with 1245 additions and 811 deletions
+3
View File
@@ -101,4 +101,7 @@ Read [cursor rules](.cursorrules).
## Frontend
The frontend is contained in the site folder.
For building Frontend refer to [this document](docs/contributing/frontend.md)
For building Frontend refer to [this document](docs/about/contributing/frontend.md)
+722 -713
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -24,6 +24,7 @@ message WorkspaceApp {
OWNER = 1;
AUTHENTICATED = 2;
PUBLIC = 3;
ORGANIZATION = 4;
}
SharingLevel sharing_level = 10;
@@ -401,10 +402,11 @@ message CreateSubAgentRequest {
TAB = 1;
}
enum Share {
enum SharingLevel {
OWNER = 0;
AUTHENTICATED = 1;
PUBLIC = 2;
ORGANIZATION = 3;
}
string slug = 1;
@@ -417,7 +419,7 @@ message CreateSubAgentRequest {
optional string icon = 8;
optional OpenIn open_in = 9;
optional int32 order = 10;
optional Share share = 11;
optional SharingLevel share = 11;
optional bool subdomain = 12;
optional string url = 13;
}
+6 -10
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
@@ -140,20 +141,15 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
health = database.WorkspaceAppHealthInitializing
}
var sharingLevel database.AppSharingLevel
switch app.GetShare() {
case agentproto.CreateSubAgentRequest_App_OWNER:
sharingLevel = database.AppSharingLevelOwner
case agentproto.CreateSubAgentRequest_App_AUTHENTICATED:
sharingLevel = database.AppSharingLevelAuthenticated
case agentproto.CreateSubAgentRequest_App_PUBLIC:
sharingLevel = database.AppSharingLevelPublic
default:
share := app.GetShare()
protoSharingLevel, ok := agentproto.CreateSubAgentRequest_App_SharingLevel_name[int32(share)]
if !ok {
return codersdk.ValidationError{
Field: "share",
Detail: fmt.Sprintf("%q is not a valid app sharing level", app.GetShare()),
Detail: fmt.Sprintf("%q is not a valid app sharing level", share.String()),
}
}
sharingLevel := database.AppSharingLevel(strings.ToLower(protoSharingLevel))
var openIn database.WorkspaceAppOpenIn
switch app.GetOpenIn() {
+7
View File
@@ -16833,6 +16833,7 @@ const docTemplate = `{
"enum": [
"owner",
"authenticated",
"organization",
"public"
],
"allOf": [
@@ -17747,6 +17748,7 @@ const docTemplate = `{
"enum": [
"owner",
"authenticated",
"organization",
"public"
],
"allOf": [
@@ -17766,11 +17768,13 @@ const docTemplate = `{
"enum": [
"owner",
"authenticated",
"organization",
"public"
],
"x-enum-varnames": [
"WorkspaceAgentPortShareLevelOwner",
"WorkspaceAgentPortShareLevelAuthenticated",
"WorkspaceAgentPortShareLevelOrganization",
"WorkspaceAgentPortShareLevelPublic"
]
},
@@ -17905,6 +17909,7 @@ const docTemplate = `{
"enum": [
"owner",
"authenticated",
"organization",
"public"
],
"allOf": [
@@ -17969,11 +17974,13 @@ const docTemplate = `{
"enum": [
"owner",
"authenticated",
"organization",
"public"
],
"x-enum-varnames": [
"WorkspaceAppSharingLevelOwner",
"WorkspaceAppSharingLevelAuthenticated",
"WorkspaceAppSharingLevelOrganization",
"WorkspaceAppSharingLevelPublic"
]
},
+7 -5
View File
@@ -15353,7 +15353,7 @@
]
},
"share_level": {
"enum": ["owner", "authenticated", "public"],
"enum": ["owner", "authenticated", "organization", "public"],
"allOf": [
{
"$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel"
@@ -16227,7 +16227,7 @@
]
},
"share_level": {
"enum": ["owner", "authenticated", "public"],
"enum": ["owner", "authenticated", "organization", "public"],
"allOf": [
{
"$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel"
@@ -16242,10 +16242,11 @@
},
"codersdk.WorkspaceAgentPortShareLevel": {
"type": "string",
"enum": ["owner", "authenticated", "public"],
"enum": ["owner", "authenticated", "organization", "public"],
"x-enum-varnames": [
"WorkspaceAgentPortShareLevelOwner",
"WorkspaceAgentPortShareLevelAuthenticated",
"WorkspaceAgentPortShareLevelOrganization",
"WorkspaceAgentPortShareLevelPublic"
]
},
@@ -16366,7 +16367,7 @@
"$ref": "#/definitions/codersdk.WorkspaceAppOpenIn"
},
"sharing_level": {
"enum": ["owner", "authenticated", "public"],
"enum": ["owner", "authenticated", "organization", "public"],
"allOf": [
{
"$ref": "#/definitions/codersdk.WorkspaceAppSharingLevel"
@@ -16418,10 +16419,11 @@
},
"codersdk.WorkspaceAppSharingLevel": {
"type": "string",
"enum": ["owner", "authenticated", "public"],
"enum": ["owner", "authenticated", "organization", "public"],
"x-enum-varnames": [
"WorkspaceAppSharingLevelOwner",
"WorkspaceAppSharingLevelAuthenticated",
"WorkspaceAppSharingLevelOrganization",
"WorkspaceAppSharingLevelPublic"
]
},
+1
View File
@@ -18,6 +18,7 @@ CREATE TYPE api_key_scope AS ENUM (
CREATE TYPE app_sharing_level AS ENUM (
'owner',
'authenticated',
'organization',
'public'
);
@@ -0,0 +1,92 @@
-- Drop the view that depends on the templates table
DROP VIEW template_with_names;
-- Remove 'organization' from the app_sharing_level enum
CREATE TYPE new_app_sharing_level AS ENUM (
'owner',
'authenticated',
'public'
);
-- Update workspace_agent_port_share table to use old enum
-- Convert any 'organization' values to 'authenticated' during downgrade
ALTER TABLE workspace_agent_port_share
ALTER COLUMN share_level TYPE new_app_sharing_level USING (
CASE
WHEN share_level = 'organization' THEN 'authenticated'::new_app_sharing_level
ELSE share_level::text::new_app_sharing_level
END
);
-- Update workspace_apps table to use old enum
-- Convert any 'organization' values to 'authenticated' during downgrade
ALTER TABLE workspace_apps
ALTER COLUMN sharing_level DROP DEFAULT,
ALTER COLUMN sharing_level TYPE new_app_sharing_level USING (
CASE
WHEN sharing_level = 'organization' THEN 'authenticated'::new_app_sharing_level
ELSE sharing_level::text::new_app_sharing_level
END
),
ALTER COLUMN sharing_level SET DEFAULT 'owner'::new_app_sharing_level;
-- Update templates table to use old enum
-- Convert any 'organization' values to 'authenticated' during downgrade
ALTER TABLE templates
ALTER COLUMN max_port_sharing_level DROP DEFAULT,
ALTER COLUMN max_port_sharing_level TYPE new_app_sharing_level USING (
CASE
WHEN max_port_sharing_level = 'organization' THEN 'owner'::new_app_sharing_level
ELSE max_port_sharing_level::text::new_app_sharing_level
END
),
ALTER COLUMN max_port_sharing_level SET DEFAULT 'owner'::new_app_sharing_level;
-- Drop old enum and rename new one
DROP TYPE app_sharing_level;
ALTER TYPE new_app_sharing_level RENAME TO app_sharing_level;
-- Recreate the template_with_names view
CREATE VIEW template_with_names AS
SELECT templates.id,
templates.created_at,
templates.updated_at,
templates.organization_id,
templates.deleted,
templates.name,
templates.provisioner,
templates.active_version_id,
templates.description,
templates.default_ttl,
templates.created_by,
templates.icon,
templates.user_acl,
templates.group_acl,
templates.display_name,
templates.allow_user_cancel_workspace_jobs,
templates.allow_user_autostart,
templates.allow_user_autostop,
templates.failure_ttl,
templates.time_til_dormant,
templates.time_til_dormant_autodelete,
templates.autostop_requirement_days_of_week,
templates.autostop_requirement_weeks,
templates.autostart_block_days_of_week,
templates.require_active_version,
templates.deprecated,
templates.activity_bump,
templates.max_port_sharing_level,
templates.use_classic_parameter_flow,
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
COALESCE(visible_users.username, ''::text) AS created_by_username,
COALESCE(visible_users.name, ''::text) AS created_by_name,
COALESCE(organizations.name, ''::text) AS organization_name,
COALESCE(organizations.display_name, ''::text) AS organization_display_name,
COALESCE(organizations.icon, ''::text) AS organization_icon
FROM ((templates
LEFT JOIN visible_users ON ((templates.created_by = visible_users.id)))
LEFT JOIN organizations ON ((templates.organization_id = organizations.id)));
COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.';
@@ -0,0 +1,73 @@
-- Drop the view that depends on the templates table
DROP VIEW template_with_names;
-- Add 'organization' to the app_sharing_level enum
CREATE TYPE new_app_sharing_level AS ENUM (
'owner',
'authenticated',
'organization',
'public'
);
-- Update workspace_agent_port_share table to use new enum
ALTER TABLE workspace_agent_port_share
ALTER COLUMN share_level TYPE new_app_sharing_level USING (share_level::text::new_app_sharing_level);
-- Update workspace_apps table to use new enum
ALTER TABLE workspace_apps
ALTER COLUMN sharing_level DROP DEFAULT,
ALTER COLUMN sharing_level TYPE new_app_sharing_level USING (sharing_level::text::new_app_sharing_level),
ALTER COLUMN sharing_level SET DEFAULT 'owner'::new_app_sharing_level;
-- Update templates table to use new enum
ALTER TABLE templates
ALTER COLUMN max_port_sharing_level DROP DEFAULT,
ALTER COLUMN max_port_sharing_level TYPE new_app_sharing_level USING (max_port_sharing_level::text::new_app_sharing_level),
ALTER COLUMN max_port_sharing_level SET DEFAULT 'owner'::new_app_sharing_level;
-- Drop old enum and rename new one
DROP TYPE app_sharing_level;
ALTER TYPE new_app_sharing_level RENAME TO app_sharing_level;
-- Recreate the template_with_names view
CREATE VIEW template_with_names AS
SELECT templates.id,
templates.created_at,
templates.updated_at,
templates.organization_id,
templates.deleted,
templates.name,
templates.provisioner,
templates.active_version_id,
templates.description,
templates.default_ttl,
templates.created_by,
templates.icon,
templates.user_acl,
templates.group_acl,
templates.display_name,
templates.allow_user_cancel_workspace_jobs,
templates.allow_user_autostart,
templates.allow_user_autostop,
templates.failure_ttl,
templates.time_til_dormant,
templates.time_til_dormant_autodelete,
templates.autostop_requirement_days_of_week,
templates.autostop_requirement_weeks,
templates.autostart_block_days_of_week,
templates.require_active_version,
templates.deprecated,
templates.activity_bump,
templates.max_port_sharing_level,
templates.use_classic_parameter_flow,
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
COALESCE(visible_users.username, ''::text) AS created_by_username,
COALESCE(visible_users.name, ''::text) AS created_by_name,
COALESCE(organizations.name, ''::text) AS organization_name,
COALESCE(organizations.display_name, ''::text) AS organization_display_name,
COALESCE(organizations.icon, ''::text) AS organization_icon
FROM ((templates
LEFT JOIN visible_users ON ((templates.created_by = visible_users.id)))
LEFT JOIN organizations ON ((templates.organization_id = organizations.id)));
COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.';
+3
View File
@@ -137,6 +137,7 @@ type AppSharingLevel string
const (
AppSharingLevelOwner AppSharingLevel = "owner"
AppSharingLevelAuthenticated AppSharingLevel = "authenticated"
AppSharingLevelOrganization AppSharingLevel = "organization"
AppSharingLevelPublic AppSharingLevel = "public"
)
@@ -179,6 +180,7 @@ func (e AppSharingLevel) Valid() bool {
switch e {
case AppSharingLevelOwner,
AppSharingLevelAuthenticated,
AppSharingLevelOrganization,
AppSharingLevelPublic:
return true
}
@@ -189,6 +191,7 @@ func AllAppSharingLevelValues() []AppSharingLevel {
return []AppSharingLevel{
AppSharingLevelOwner,
AppSharingLevelAuthenticated,
AppSharingLevelOrganization,
AppSharingLevelPublic,
}
}
+23 -2
View File
@@ -258,7 +258,7 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
return &token, tokenStr, true
}
// authorizeRequest returns true/false if the request is authorized. The returned []string
// authorizeRequest returns true if the request is authorized. The returned []string
// are warnings that aid in debugging. These messages do not prevent authorization,
// but may indicate that the request is not configured correctly.
// If an error is returned, the request should be aborted with a 500 error.
@@ -310,7 +310,7 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj
// This is not ideal to check for the 'owner' role, but we are only checking
// to determine whether to show a warning for debugging reasons. This does
// not do any authz checks, so it is ok.
if roles != nil && slices.Contains(roles.Roles.Names(), rbac.RoleOwner()) {
if slices.Contains(roles.Roles.Names(), rbac.RoleOwner()) {
warnings = append(warnings, "path-based apps with \"owner\" share level are only accessible by the workspace owner (see --dangerous-allow-path-app-site-owner-access)")
}
return false, warnings, nil
@@ -354,6 +354,27 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj
if err == nil {
return true, []string{}, nil
}
case database.AppSharingLevelOrganization:
// Check if the user is a member of the same organization as the workspace
// First check if they have permission to connect to their own workspace (enforces scopes)
err := p.Authorizer.Authorize(ctx, *roles, rbacAction, rbacResourceOwned)
if err != nil {
return false, warnings, nil
}
// Check if the user is a member of the workspace's organization
workspaceOrgID := dbReq.Workspace.OrganizationID
expandedRoles, err := roles.Roles.Expand()
if err != nil {
return false, warnings, xerrors.Errorf("expand roles: %w", err)
}
for _, role := range expandedRoles {
if _, ok := role.Org[workspaceOrgID.String()]; ok {
return true, []string{}, nil
}
}
// User is not a member of the workspace's organization
return false, warnings, nil
case database.AppSharingLevelPublic:
// We don't really care about scopes and stuff if it's public anyways.
// Someone with a restricted-scope API key could just not submit the API
+50 -2
View File
@@ -7,11 +7,13 @@ import (
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
const (
WorkspaceAgentPortShareLevelOwner WorkspaceAgentPortShareLevel = "owner"
WorkspaceAgentPortShareLevelAuthenticated WorkspaceAgentPortShareLevel = "authenticated"
WorkspaceAgentPortShareLevelOrganization WorkspaceAgentPortShareLevel = "organization"
WorkspaceAgentPortShareLevelPublic WorkspaceAgentPortShareLevel = "public"
WorkspaceAgentPortShareProtocolHTTP WorkspaceAgentPortShareProtocol = "http"
@@ -24,7 +26,7 @@ type (
UpsertWorkspaceAgentPortShareRequest struct {
AgentName string `json:"agent_name"`
Port int32 `json:"port"`
ShareLevel WorkspaceAgentPortShareLevel `json:"share_level" enums:"owner,authenticated,public"`
ShareLevel WorkspaceAgentPortShareLevel `json:"share_level" enums:"owner,authenticated,organization,public"`
Protocol WorkspaceAgentPortShareProtocol `json:"protocol" enums:"http,https"`
}
WorkspaceAgentPortShares struct {
@@ -34,7 +36,7 @@ type (
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
AgentName string `json:"agent_name"`
Port int32 `json:"port"`
ShareLevel WorkspaceAgentPortShareLevel `json:"share_level" enums:"owner,authenticated,public"`
ShareLevel WorkspaceAgentPortShareLevel `json:"share_level" enums:"owner,authenticated,organization,public"`
Protocol WorkspaceAgentPortShareProtocol `json:"protocol" enums:"http,https"`
}
DeleteWorkspaceAgentPortShareRequest struct {
@@ -46,14 +48,60 @@ type (
func (l WorkspaceAgentPortShareLevel) ValidMaxLevel() bool {
return l == WorkspaceAgentPortShareLevelOwner ||
l == WorkspaceAgentPortShareLevelAuthenticated ||
l == WorkspaceAgentPortShareLevelOrganization ||
l == WorkspaceAgentPortShareLevelPublic
}
func (l WorkspaceAgentPortShareLevel) ValidPortShareLevel() bool {
return l == WorkspaceAgentPortShareLevelAuthenticated ||
l == WorkspaceAgentPortShareLevelOrganization ||
l == WorkspaceAgentPortShareLevelPublic
}
// IsCompatibleWithMaxLevel determines whether the sharing level is valid under
// the specified maxLevel. The values are fully ordered, from "highest" to
// "lowest" as
// 1. Public
// 2. Authenticated
// 3. Organization
// 4. Owner
// Returns an error if either level is invalid.
func (l WorkspaceAgentPortShareLevel) IsCompatibleWithMaxLevel(maxLevel WorkspaceAgentPortShareLevel) error {
// Owner is always allowed.
if l == WorkspaceAgentPortShareLevelOwner {
return nil
}
// If public is allowed, anything is allowed.
if maxLevel == WorkspaceAgentPortShareLevelPublic {
return nil
}
// Public is not allowed.
if l == WorkspaceAgentPortShareLevelPublic {
return xerrors.Errorf("%q sharing level is not allowed under max level %q", l, maxLevel)
}
// If authenticated is allowed, public has already been filtered out so
// anything is allowed.
if maxLevel == WorkspaceAgentPortShareLevelAuthenticated {
return nil
}
// Authenticated is not allowed.
if l == WorkspaceAgentPortShareLevelAuthenticated {
return xerrors.Errorf("%q sharing level is not allowed under max level %q", l, maxLevel)
}
// If organization is allowed, public and authenticated have already been
// filtered out so anything is allowed.
if maxLevel == WorkspaceAgentPortShareLevelOrganization {
return nil
}
// Organization is not allowed.
if l == WorkspaceAgentPortShareLevelOrganization {
return xerrors.Errorf("%q sharing level is not allowed under max level %q", l, maxLevel)
}
// An invalid value was provided.
return xerrors.New("port sharing level is invalid.")
}
func (p WorkspaceAgentPortShareProtocol) ValidPortProtocol() bool {
return p == WorkspaceAgentPortShareProtocolHTTP ||
p == WorkspaceAgentPortShareProtocolHTTPS
+3 -1
View File
@@ -35,12 +35,14 @@ type WorkspaceAppSharingLevel string
const (
WorkspaceAppSharingLevelOwner WorkspaceAppSharingLevel = "owner"
WorkspaceAppSharingLevelAuthenticated WorkspaceAppSharingLevel = "authenticated"
WorkspaceAppSharingLevelOrganization WorkspaceAppSharingLevel = "organization"
WorkspaceAppSharingLevelPublic WorkspaceAppSharingLevel = "public"
)
var MapWorkspaceAppSharingLevels = map[WorkspaceAppSharingLevel]struct{}{
WorkspaceAppSharingLevelOwner: {},
WorkspaceAppSharingLevelAuthenticated: {},
WorkspaceAppSharingLevelOrganization: {},
WorkspaceAppSharingLevelPublic: {},
}
@@ -79,7 +81,7 @@ type WorkspaceApp struct {
Subdomain bool `json:"subdomain"`
// SubdomainName is the application domain exposed on the `coder server`.
SubdomainName string `json:"subdomain_name,omitempty"`
SharingLevel WorkspaceAppSharingLevel `json:"sharing_level" enums:"owner,authenticated,public"`
SharingLevel WorkspaceAppSharingLevel `json:"sharing_level" enums:"owner,authenticated,organization,public"`
// Healthcheck specifies the configuration for checking app health.
Healthcheck Healthcheck `json:"healthcheck,omitempty"`
Health WorkspaceAppHealth `json:"health"`
+2
View File
@@ -926,6 +926,7 @@ Status Code **200**
| `open_in` | `tab` |
| `sharing_level` | `owner` |
| `sharing_level` | `authenticated` |
| `sharing_level` | `organization` |
| `sharing_level` | `public` |
| `state` | `working` |
| `state` | `complete` |
@@ -1681,6 +1682,7 @@ Status Code **200**
| `open_in` | `tab` |
| `sharing_level` | `owner` |
| `sharing_level` | `authenticated` |
| `sharing_level` | `organization` |
| `sharing_level` | `public` |
| `state` | `working` |
| `state` | `complete` |
+5
View File
@@ -8084,6 +8084,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `protocol` | `https` |
| `share_level` | `owner` |
| `share_level` | `authenticated` |
| `share_level` | `organization` |
| `share_level` | `public` |
## codersdk.UsageAppName
@@ -9287,6 +9288,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `protocol` | `https` |
| `share_level` | `owner` |
| `share_level` | `authenticated` |
| `share_level` | `organization` |
| `share_level` | `public` |
## codersdk.WorkspaceAgentPortShareLevel
@@ -9303,6 +9305,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|-----------------|
| `owner` |
| `authenticated` |
| `organization` |
| `public` |
## codersdk.WorkspaceAgentPortShareProtocol
@@ -9473,6 +9476,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|-----------------|-----------------|
| `sharing_level` | `owner` |
| `sharing_level` | `authenticated` |
| `sharing_level` | `organization` |
| `sharing_level` | `public` |
## codersdk.WorkspaceAppHealth
@@ -9521,6 +9525,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|-----------------|
| `owner` |
| `authenticated` |
| `organization` |
| `public` |
## codersdk.WorkspaceAppStatus
+4
View File
@@ -143,6 +143,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|------------------------|-----------------|
| `max_port_share_level` | `owner` |
| `max_port_share_level` | `authenticated` |
| `max_port_share_level` | `organization` |
| `max_port_share_level` | `public` |
| `provisioner` | `terraform` |
@@ -874,6 +875,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|------------------------|-----------------|
| `max_port_share_level` | `owner` |
| `max_port_share_level` | `authenticated` |
| `max_port_share_level` | `organization` |
| `max_port_share_level` | `public` |
| `provisioner` | `terraform` |
@@ -2552,6 +2554,7 @@ Status Code **200**
| `open_in` | `tab` |
| `sharing_level` | `owner` |
| `sharing_level` | `authenticated` |
| `sharing_level` | `organization` |
| `sharing_level` | `public` |
| `state` | `working` |
| `state` | `complete` |
@@ -3227,6 +3230,7 @@ Status Code **200**
| `open_in` | `tab` |
| `sharing_level` | `owner` |
| `sharing_level` | `authenticated` |
| `sharing_level` | `organization` |
| `sharing_level` | `public` |
| `state` | `working` |
| `state` | `complete` |
@@ -112,6 +112,8 @@ match our `coder_app`s share option in
- `owner` (Default): The implicit sharing level for all listening ports, only
visible to the workspace owner
- `organization`: Accessible by authenticated users in the same organization as
the workspace.
- `authenticated`: Accessible by other authenticated Coder users on the same
deployment.
- `public`: Accessible by any user with the associated URL.
+2 -15
View File
@@ -15,25 +15,12 @@ func NewEnterprisePortSharer() *EnterprisePortSharer {
func (EnterprisePortSharer) AuthorizedLevel(template database.Template, level codersdk.WorkspaceAgentPortShareLevel) error {
maxLevel := codersdk.WorkspaceAgentPortShareLevel(template.MaxPortSharingLevel)
switch level {
case codersdk.WorkspaceAgentPortShareLevelPublic:
if maxLevel != codersdk.WorkspaceAgentPortShareLevelPublic {
return xerrors.Errorf("port sharing level not allowed. Max level is '%s'", maxLevel)
}
case codersdk.WorkspaceAgentPortShareLevelAuthenticated:
if maxLevel == codersdk.WorkspaceAgentPortShareLevelOwner {
return xerrors.Errorf("port sharing level not allowed. Max level is '%s'", maxLevel)
}
default:
return xerrors.New("port sharing level is invalid.")
}
return nil
return level.IsCompatibleWithMaxLevel(maxLevel)
}
func (EnterprisePortSharer) ValidateTemplateMaxLevel(level codersdk.WorkspaceAgentPortShareLevel) error {
if !level.ValidMaxLevel() {
return xerrors.New("invalid max port sharing level, value must be 'authenticated' or 'public'.")
return xerrors.New("invalid max port sharing level, value must be 'authenticated', 'organization', or 'public'.")
}
return nil
+67 -12
View File
@@ -8,23 +8,20 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
func TestWorkspacePortShare(t *testing.T) {
func TestWorkspacePortSharePublic(t *testing.T) {
t.Parallel()
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureControlSharedPorts: 1,
},
Features: license.Features{codersdk.FeatureControlSharedPorts: 1},
},
})
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
@@ -35,8 +32,12 @@ func TestWorkspacePortShare(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// try to update port share with template max port share level owner
_, err := client.UpsertWorkspaceAgentPortShare(ctx, r.workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
templ, err := client.Template(ctx, r.workspace.TemplateID)
require.NoError(t, err)
require.Equal(t, templ.MaxPortShareLevel, codersdk.WorkspaceAgentPortShareLevelOwner)
// Try to update port share with template max port share level owner.
_, err = client.UpsertWorkspaceAgentPortShare(ctx, r.workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
AgentName: r.sdkAgent.Name,
Port: 8080,
ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic,
@@ -44,10 +45,9 @@ func TestWorkspacePortShare(t *testing.T) {
})
require.Error(t, err, "Port sharing level not allowed")
// update the template max port share level to public
var level codersdk.WorkspaceAgentPortShareLevel = codersdk.WorkspaceAgentPortShareLevelPublic
// Update the template max port share level to public
client.UpdateTemplateMeta(ctx, r.workspace.TemplateID, codersdk.UpdateTemplateMeta{
MaxPortShareLevel: &level,
MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevelPublic),
})
// OK
@@ -60,3 +60,58 @@ func TestWorkspacePortShare(t *testing.T) {
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceAgentPortShareLevelPublic, ps.ShareLevel)
}
func TestWorkspacePortShareOrganization(t *testing.T) {
t.Parallel()
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureControlSharedPorts: 1},
},
})
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
r := setupWorkspaceAgent(t, client, codersdk.CreateFirstUserResponse{
UserID: user.ID,
OrganizationID: owner.OrganizationID,
}, 0)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
templ, err := client.Template(ctx, r.workspace.TemplateID)
require.NoError(t, err)
require.Equal(t, templ.MaxPortShareLevel, codersdk.WorkspaceAgentPortShareLevelOwner)
// Try to update port share with template max port share level owner
_, err = client.UpsertWorkspaceAgentPortShare(ctx, r.workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
AgentName: r.sdkAgent.Name,
Port: 8080,
ShareLevel: codersdk.WorkspaceAgentPortShareLevelOrganization,
Protocol: codersdk.WorkspaceAgentPortShareProtocolHTTP,
})
require.Error(t, err, "Port sharing level not allowed")
// Update the template max port share level to organization
client.UpdateTemplateMeta(ctx, r.workspace.TemplateID, codersdk.UpdateTemplateMeta{
MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevelOrganization),
})
// Try to share a port publicly with template max port share level organization
_, err = client.UpsertWorkspaceAgentPortShare(ctx, r.workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
AgentName: r.sdkAgent.Name,
Port: 8080,
ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic,
Protocol: codersdk.WorkspaceAgentPortShareProtocolHTTP,
})
require.Error(t, err, "Port sharing level not allowed")
// OK
ps, err := client.UpsertWorkspaceAgentPortShare(ctx, r.workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
AgentName: r.sdkAgent.Name,
Port: 8080,
ShareLevel: codersdk.WorkspaceAgentPortShareLevelOrganization,
Protocol: codersdk.WorkspaceAgentPortShareProtocolHTTP,
})
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceAgentPortShareLevelOrganization, ps.ShareLevel)
}
+12 -2
View File
@@ -3491,10 +3491,15 @@ export interface WorkspaceAgentPortShare {
}
// From codersdk/workspaceagentportshare.go
export type WorkspaceAgentPortShareLevel = "authenticated" | "owner" | "public";
export type WorkspaceAgentPortShareLevel =
| "authenticated"
| "organization"
| "owner"
| "public";
export const WorkspaceAgentPortShareLevels: WorkspaceAgentPortShareLevel[] = [
"authenticated",
"organization",
"owner",
"public",
];
@@ -3584,10 +3589,15 @@ export type WorkspaceAppOpenIn = "slim-window" | "tab";
export const WorkspaceAppOpenIns: WorkspaceAppOpenIn[] = ["slim-window", "tab"];
// From codersdk/workspaceapps.go
export type WorkspaceAppSharingLevel = "authenticated" | "owner" | "public";
export type WorkspaceAppSharingLevel =
| "authenticated"
| "organization"
| "owner"
| "public";
export const WorkspaceAppSharingLevels: WorkspaceAppSharingLevel[] = [
"authenticated",
"organization",
"owner",
"public",
];
+13 -13
View File
@@ -84,20 +84,20 @@ export const HelpTooltipTrigger = forwardRef<
ref={ref}
css={[
css`
display: flex;
align-items: center;
justify-content: center;
padding: 4px 0;
border: 0;
background: transparent;
cursor: pointer;
color: inherit;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 0;
border: 0;
background: transparent;
cursor: pointer;
color: inherit;
& svg {
width: ${getIconSpacingFromSize(size)}px;
height: ${getIconSpacingFromSize(size)}px;
}
`,
& svg {
width: ${getIconSpacingFromSize(size)}px;
height: ${getIconSpacingFromSize(size)}px;
}
`,
hoverEffect ? hoverEffectStyles : null,
]}
>
+13 -5
View File
@@ -14,9 +14,11 @@ export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
disablePortal?: boolean;
}
>(({ className, sideOffset = 4, disablePortal, ...props }, ref) => {
const content = (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
@@ -30,5 +32,11 @@ export const TooltipContent = React.forwardRef<
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
);
return disablePortal ? (
content
) : (
<TooltipPrimitive.Portal>{content}</TooltipPrimitive.Portal>
);
});
@@ -99,6 +99,17 @@ export const SharingLevelAuthenticated: Story = {
},
};
export const SharingLevelOrganization: Story = {
args: {
workspace: MockWorkspace,
app: {
...MockWorkspaceApp,
sharing_level: "organization",
},
agent: MockWorkspaceAgent,
},
};
export const SharingLevelPublic: Story = {
args: {
workspace: MockWorkspace,
@@ -1,3 +1,4 @@
import BusinessIcon from "@mui/icons-material/Business";
import GroupOutlinedIcon from "@mui/icons-material/GroupOutlined";
import PublicOutlinedIcon from "@mui/icons-material/PublicOutlined";
import Tooltip from "@mui/material/Tooltip";
@@ -23,6 +24,13 @@ export const ShareIcon = ({ app }: ShareIconProps) => {
</Tooltip>
);
}
if (app.sharing_level === "organization") {
return (
<Tooltip title="Shared with organization members">
<BusinessIcon />
</Tooltip>
);
}
if (app.sharing_level === "public") {
return (
<Tooltip title="Shared publicly">
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import {
MockListeningPortsResponse,
MockSharedPortsResponse,
@@ -14,6 +15,7 @@ const meta: Meta<typeof PortForwardButton> = {
component: PortForwardButton,
decorators: [withDashboardProvider],
args: {
host: "*.coder.com",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
template: MockTemplate,
@@ -36,6 +38,11 @@ export const Example: Story = {
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
},
};
export const Loading: Story = {};
@@ -1,4 +1,5 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import BusinessIcon from "@mui/icons-material/Business";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import SensorsIcon from "@mui/icons-material/Sensors";
@@ -8,7 +9,6 @@ import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import MUITooltip from "@mui/material/Tooltip";
import { API } from "api/api";
import {
deleteWorkspacePortShare,
@@ -207,16 +207,50 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
);
const canSharePortsPublic =
canSharePorts && template.max_port_share_level === "public";
const canSharePortsAuthenticated =
canSharePorts &&
(template.max_port_share_level === "authenticated" || canSharePortsPublic);
const defaultShareLevel =
template.max_port_share_level === "organization"
? "organization"
: "authenticated";
const disabledPublicMenuItem = (
<MUITooltip title="This workspace template does not allow sharing ports with unauthenticated users.">
{/* Tooltips don't work directly on disabled MenuItem components so you must wrap in div. */}
<div>
<MenuItem value="public" disabled>
Public
</MenuItem>
</div>
</MUITooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* Tooltips don't work directly on disabled MenuItem components so you must wrap in div. */}
<div>
<MenuItem value="public" disabled>
Public
</MenuItem>
</div>
</TooltipTrigger>
<TooltipContent disablePortal>
This workspace template does not allow sharing ports publicly.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const disabledAuthenticatedMenuItem = (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* Tooltips don't work directly on disabled MenuItem components so you must wrap in div. */}
<div>
<MenuItem value="authenticated" disabled>
Authenticated
</MenuItem>
</div>
</TooltipTrigger>
<TooltipContent disablePortal>
This workspace template does not allow sharing ports outside of its
organization.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
return (
@@ -311,7 +345,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
<span className="sr-only">Connect to port</span>
</Button>
</TooltipTrigger>
<TooltipContent>Connect to port</TooltipContent>
<TooltipContent disablePortal>
Connect to port
</TooltipContent>
</Tooltip>
</TooltipProvider>
</form>
@@ -379,7 +415,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
agent_name: agent.name,
port: port.port,
protocol: listeningPortProtocol,
share_level: "authenticated",
share_level: defaultShareLevel,
});
}}
>
@@ -387,7 +423,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
<span className="sr-only">Share</span>
</Button>
</TooltipTrigger>
<TooltipContent>Share this port</TooltipContent>
<TooltipContent disablePortal>
Share this port
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
@@ -406,7 +444,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
<HelpTooltipTitle>Shared Ports</HelpTooltipTitle>
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
{canSharePorts
? "Ports can be shared with other Coder users or with the public."
? "Ports can be shared with organization members, other Coder users, or with the public."
: "This workspace template does not allow sharing ports. Contact a template administrator to enable port sharing."}
</HelpTooltipText>
{canSharePorts && (
@@ -437,6 +475,8 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
>
{share.share_level === "public" ? (
<LockOpenIcon css={{ width: 14, height: 14 }} />
) : share.share_level === "organization" ? (
<BusinessIcon css={{ width: 14, height: 14 }} />
) : (
<LockIcon css={{ width: 14, height: 14 }} />
)}
@@ -479,7 +519,14 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
});
}}
>
<MenuItem value="authenticated">Authenticated</MenuItem>
<MenuItem value="organization">Organization</MenuItem>
{canSharePortsAuthenticated ? (
<MenuItem value="authenticated">
Authenticated
</MenuItem>
) : (
disabledAuthenticatedMenuItem
)}
{canSharePortsPublic ? (
<MenuItem value="public">Public</MenuItem>
) : (
@@ -546,7 +593,12 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
value={form.values.share_level}
label="Sharing Level"
>
<MenuItem value="authenticated">Authenticated</MenuItem>
<MenuItem value="organization">Organization</MenuItem>
{canSharePortsAuthenticated ? (
<MenuItem value="authenticated">Authenticated</MenuItem>
) : (
disabledAuthenticatedMenuItem
)}
{canSharePortsPublic ? (
<MenuItem value="public">Public</MenuItem>
) : (
@@ -568,11 +620,11 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
const classNames = {
paper: (css, theme) => css`
padding: 0;
width: 404px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
padding: 0;
width: 404px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
} satisfies Record<string, ClassName>;
const styles = {
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import {
MockListeningPortsResponse,
MockSharedPortsResponse,
@@ -26,11 +27,13 @@ const meta: Meta<typeof PortForwardPopoverView> = {
),
],
args: {
listeningPorts: MockListeningPortsResponse.ports,
sharedPorts: MockSharedPortsResponse.shares,
agent: MockWorkspaceAgent,
template: MockTemplate,
workspace: MockWorkspace,
portSharingControlsEnabled: true,
host: "coder.com",
host: "*.coder.com",
},
};
@@ -51,7 +54,6 @@ export const WithManyPorts: Story = {
network: "",
port: 3000 + i,
})),
sharedPorts: MockSharedPortsResponse.shares,
},
};
@@ -64,7 +66,6 @@ export const Empty: Story = {
export const AGPLPortSharing: Story = {
args: {
listeningPorts: MockListeningPortsResponse.ports,
portSharingControlsEnabled: false,
sharedPorts: MockSharedPortsResponse.shares,
},
@@ -72,8 +73,6 @@ export const AGPLPortSharing: Story = {
export const EnterprisePortSharingControlsOwner: Story = {
args: {
listeningPorts: MockListeningPortsResponse.ports,
sharedPorts: [],
template: {
...MockTemplate,
max_port_share_level: "owner",
@@ -83,13 +82,29 @@ export const EnterprisePortSharingControlsOwner: Story = {
export const EnterprisePortSharingControlsAuthenticated: Story = {
args: {
listeningPorts: MockListeningPortsResponse.ports,
template: {
...MockTemplate,
max_port_share_level: "authenticated",
},
sharedPorts: MockSharedPortsResponse.shares.filter((share) => {
return share.share_level === "authenticated";
}),
sharedPorts: MockSharedPortsResponse.shares.filter(
(share) => share.share_level === "authenticated",
),
},
};
export const DisabledOptions: Story = {
args: {
template: {
...MockTemplate,
max_port_share_level: "organization",
},
sharedPorts: MockSharedPortsResponse.shares.filter(
(share) => share.share_level === "organization",
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdown = canvas.getByLabelText("Sharing Level");
await userEvent.click(dropdown);
},
};
@@ -305,6 +305,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
label="Maximum Port Sharing Level"
>
<MenuItem value="owner">Owner</MenuItem>
<MenuItem value="organization">Organization</MenuItem>
<MenuItem value="authenticated">Authenticated</MenuItem>
<MenuItem value="public">Public</MenuItem>
</TextField>
+7
View File
@@ -4002,6 +4002,13 @@ export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = {
share_level: "authenticated",
protocol: "http",
},
{
workspace_id: MockWorkspace.id,
agent_name: "a-workspace-agent",
port: 4443,
share_level: "organization",
protocol: "http",
},
{
workspace_id: MockWorkspace.id,
agent_name: "a-workspace-agent",
+1
View File
@@ -55,6 +55,7 @@ import (
// - Added support for CreateSubAgent RPC on the Agent API.
// - Added support for DeleteSubAgent RPC on the Agent API.
// - Added support for ListSubAgents RPC on the Agent API.
// - Add ORGANIZATION SharingLevel
const (
CurrentMajor = 2
CurrentMinor = 6