mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add organization scope for shared ports (#18314)
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Generated
+7
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+7
-5
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+1
@@ -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.';
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Generated
+2
@@ -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` |
|
||||
|
||||
Generated
+5
@@ -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
|
||||
|
||||
Generated
+4
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+12
-2
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user