feat(containers): add securityOpt

pull/12408/head
dylan 2024-11-28 17:10:56 +08:00
parent f2e7680bf3
commit 8b47bdf8b4
22 changed files with 165 additions and 8 deletions

View File

@ -117,6 +117,7 @@ func setEndpointAuthorizations(endpoint *portainer.Endpoint) {
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowSecurityOptForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,

View File

@ -91,6 +91,7 @@
"allowPrivilegedModeForRegularUsers": true,
"allowStackManagementForRegularUsers": true,
"allowSysctlSettingForRegularUsers": false,
"allowSecurityOptForRegularUsers": false,
"allowVolumeBrowserForRegularUsers": false,
"enableHostManagementFeatures": false
},

View File

@ -551,6 +551,7 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.Data
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowSecurityOptForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,

View File

@ -26,6 +26,8 @@ type endpointSettingsUpdatePayload struct {
AllowContainerCapabilitiesForRegularUsers *bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
// Whether non-administrator should be able to use sysctl settings
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
// Whether non-administrator should be able to use security-opt settings
AllowSecurityOptForRegularUsers *bool `json:"allowSecurityOptForRegularUsers" example:"true"`
// Whether host management features are enabled
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
@ -107,6 +109,10 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
securitySettings.AllowSysctlSettingForRegularUsers = *payload.AllowSysctlSettingForRegularUsers
}
if payload.AllowSecurityOptForRegularUsers != nil {
securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers
}
if payload.EnableHostManagementFeatures != nil {
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
}

View File

@ -169,13 +169,14 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
type PartialContainer struct {
HostConfig struct {
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
SecurityOpt []string `json:"SecurityOpt"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
} `json:"HostConfig"`
}
@ -225,6 +226,10 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return forbiddenResponse, ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowSecurityOptForRegularUsers && len(partialContainer.HostConfig.SecurityOpt) > 0 {
return forbiddenResponse, errors.New("forbidden to use security-opt settings")
}
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) {
return nil, ErrContainerCapabilitiesForbidden
}

View File

@ -98,6 +98,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore dataservices.
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowSecurityOptForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
@ -161,6 +162,7 @@ func createUnsecuredEndpoint(endpointURL string, dataStore dataservices.DataStor
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowSecurityOptForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,

View File

@ -521,6 +521,8 @@ type (
AllowContainerCapabilitiesForRegularUsers bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
// Whether non-administrator should be able to use sysctl settings
AllowSysctlSettingForRegularUsers bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
// Whether non-administrator should be able to use security-opt settings
AllowSecurityOptForRegularUsers bool `json:"allowSecurityOptForRegularUsers" example:"true"`
// Whether host management features are enabled
EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true"`
}

View File

@ -77,6 +77,7 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
!securitySettings.AllowHostNamespaceForRegularUsers ||
!securitySettings.AllowDeviceMappingForRegularUsers ||
!securitySettings.AllowSysctlSettingForRegularUsers ||
!securitySettings.AllowSecurityOptForRegularUsers ||
!securitySettings.AllowContainerCapabilitiesForRegularUsers) &&
!isAdminOrEndpointAdmin {

View File

@ -56,6 +56,10 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
return errors.New("sysctl setting disabled for non administrator users")
}
if !securitySettings.AllowSecurityOptForRegularUsers && service.SecurityOpt != nil && len(service.SecurityOpt) > 0 {
return errors.New("security-opt setting disabled for non administrator users")
}
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
return errors.New("container capabilities disabled for non administrator users")
}

View File

@ -318,6 +318,16 @@
</table>
</td>
</tr>
<tr ng-if="!(container.HostConfig.SecurityOpt | emptyobject)">
<td>SecurityOpt</td>
<td>
<table class="table-bordered table-condensed table">
<tr ng-repeat="opt in container.HostConfig.SecurityOpt">
<td>{{ opt }}</td>
</tr>
</table>
</td>
</tr>
<tr ng-if="container.HostConfig.DeviceRequests.length">
<td>GPUS</td>
<td>{{ computeDockerGPUCommand() }}</td>

View File

@ -112,6 +112,7 @@ angular.module('portainer.docker').controller('ContainerController', [
allowHostNamespaceForRegularUsers,
allowDeviceMappingForRegularUsers,
allowSysctlSettingForRegularUsers,
allowSecurityOptForRegularUsers,
allowBindMountsForRegularUsers,
allowPrivilegedModeForRegularUsers,
} = endpoint.SecuritySettings;
@ -121,6 +122,7 @@ angular.module('portainer.docker').controller('ContainerController', [
!allowBindMountsForRegularUsers ||
!allowDeviceMappingForRegularUsers ||
!allowSysctlSettingForRegularUsers ||
!allowSecurityOptForRegularUsers ||
!allowHostNamespaceForRegularUsers ||
!allowPrivilegedModeForRegularUsers;

View File

@ -25,6 +25,7 @@ export default class DockerFeaturesConfigurationController {
disableDeviceMappingForRegularUsers: false,
disableContainerCapabilitiesForRegularUsers: false,
disableSysctlSettingForRegularUsers: false,
disableSecurityOptForRegularUsers: false,
};
this.isAgent = false;
@ -49,6 +50,7 @@ export default class DockerFeaturesConfigurationController {
this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers');
this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers');
this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers');
this.onChangeDisableSecurityOptForRegularUsers = this.onChangeField('disableSecurityOptForRegularUsers');
}
onToggleAutoUpdate(value) {
@ -94,6 +96,7 @@ export default class DockerFeaturesConfigurationController {
disableDeviceMappingForRegularUsers,
disableContainerCapabilitiesForRegularUsers,
disableSysctlSettingForRegularUsers,
disableSecurityOptForRegularUsers,
} = this.formValues;
return (
disableBindMountsForRegularUsers ||
@ -101,7 +104,8 @@ export default class DockerFeaturesConfigurationController {
disablePrivilegedModeForRegularUsers ||
disableDeviceMappingForRegularUsers ||
disableContainerCapabilitiesForRegularUsers ||
disableSysctlSettingForRegularUsers
disableSysctlSettingForRegularUsers ||
disableSecurityOptForRegularUsers
);
}
@ -123,6 +127,7 @@ export default class DockerFeaturesConfigurationController {
allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers,
allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers,
allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers,
allowSecurityOptForRegularUsers: !this.formValues.disableSecurityOptForRegularUsers,
enableGPUManagement: this.state.enableGPUManagement,
gpus,
};
@ -177,6 +182,7 @@ export default class DockerFeaturesConfigurationController {
disableStackManagementForRegularUsers: !securitySettings.allowStackManagementForRegularUsers,
disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers,
disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers,
disableSecurityOptForRegularUsers: !securitySettings.allowSecurityOptForRegularUsers,
};
// this.endpoint.Gpus could be null as it is Gpus: []Pair in the API

View File

@ -59,6 +59,20 @@
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="$ctrl.state.autoUpdateSettings.Enabled"
name="'disableSecurityOptForRegularUsers'"
label="'Enable Change Window'"
label-class="'col-sm-7 col-lg-4'"
feature-id="$ctrl.limitedFeatureAutoUpdate"
tooltip="'Specify a time-frame during which GitOps updates can occur in this environment.'"
on-change="($ctrl.onToggleAutoUpdate)"
>
</por-switch-field>
</div>
</div>
<!-- security -->
<div class="col-sm-12 form-section-title"> Docker security settings </div>
@ -142,6 +156,17 @@
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="$ctrl.formValues.disableSecurityOptForRegularUsers"
name="'disableSecurityOptForRegularUsers'"
label="'Disable security-opt for non-administrators'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableSecurityOptForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group" ng-if="$ctrl.isContainerEditDisabled()">
<span class="col-sm-12 text-muted small">

View File

@ -32,6 +32,19 @@
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="formValues.enabled"
name="'disableSecurityOptForRegularUsers'"
label="'Enable pod security constraints'"
feature-id="limitedFeaturePodSecurityPolicy"
label-class="'col-sm-3 col-lg-2 px-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>

View File

@ -103,6 +103,7 @@ export function createMockEnvironment(): Environment {
allowHostNamespaceForRegularUsers: false,
allowStackManagementForRegularUsers: false,
allowSysctlSettingForRegularUsers: false,
allowSecurityOptForRegularUsers: false,
allowVolumeBrowserForRegularUsers: false,
enableHostManagementFeatures: false,
},

View File

@ -176,6 +176,11 @@ export function InnerForm({
environment.SecuritySettings
.allowSysctlSettingForRegularUsers
}
isSecurityOptFieldVisible={
isEnvironmentAdminQuery.authorized ||
environment.SecuritySettings
.allowSecurityOptForRegularUsers
}
renderLimits={
isDuplicate
? (values) => (

View File

@ -13,6 +13,7 @@ import { GpuFieldset, GpuFieldsetValues } from './GpuFieldset';
import { Values as RuntimeValues, RuntimeSection } from './RuntimeSection';
import { DevicesField, Values as Devices } from './DevicesField';
import { SysctlsField, Values as Sysctls } from './SysctlsField';
import { SecurityOptField, Values as SecurityOpt } from './SecurityOptField';
import {
ResourceFieldset,
Values as ResourcesValues,
@ -24,6 +25,7 @@ export interface Values {
devices: Devices;
sysctls: Sysctls;
securityOpt: SecurityOpt;
sharedMemorySize: number;
@ -40,6 +42,7 @@ export function ResourcesTab({
isInitFieldVisible,
isDevicesFieldVisible,
isSysctlFieldVisible,
isSecurityOptFieldVisible,
renderLimits,
}: {
values: Values;
@ -49,6 +52,7 @@ export function ResourcesTab({
isInitFieldVisible: boolean;
isDevicesFieldVisible: boolean;
isSysctlFieldVisible: boolean;
isSecurityOptFieldVisible: boolean;
renderLimits?: (values: ResourcesValues) => ReactNode;
}) {
const environmentId = useEnvironmentId();
@ -88,6 +92,13 @@ export function ResourcesTab({
/>
)}
{isSecurityOptFieldVisible && (
<SecurityOptField
values={values.securityOpt}
onChange={(securityOpt) => setFieldValue('securityOpt', securityOpt)}
/>
)}
<FormControl label="Shared memory size" inputId="shm-size">
<div className="flex items-center gap-4">
<Input

View File

@ -0,0 +1,54 @@
import { FormikErrors } from 'formik';
import { array, SchemaOf, string } from 'yup';
import { FormError } from '@@/form-components/FormError';
import { InputList, ItemProps } from '@@/form-components/InputList';
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
export type Values = Array<string>;
export function SecurityOptField({
values,
onChange,
errors,
}: {
values: Values;
onChange: (value: Values) => void;
errors?: FormikErrors<string>[];
}) {
return (
<InputList
value={values}
onChange={onChange}
item={Item}
addLabel="Add security-opt"
label="SecurityOpt"
errors={errors}
itemBuilder={() => ''}
/>
);
}
function Item({ item, onChange, error }: ItemProps<string>) {
return (
<div className="w-full">
<div className="flex w-full gap-4">
<InputLabeled
value={item}
onChange={(e) => onChange(e.target.value)}
label="Security Option"
placeholder="e.g. seccomp=unconfined"
className="w-full"
size="small"
/>
</div>
{error && <FormError>{Object.values(error)[0]}</FormError>}
</div>
);
}
export function securityOptValidation(): SchemaOf<Values> {
return array(
string().required('Security option is required')
);
}

View File

@ -24,6 +24,7 @@ export function toRequest(
Sysctls: Object.fromEntries(
values.sysctls.map((sysctl) => [sysctl.name, sysctl.value])
),
SecurityOpt: values.securityOpt,
ShmSize: toConfigMemory(values.sharedMemorySize),
DeviceRequests: gpuFieldsetUtils.toRequest(
oldConfig.HostConfig.DeviceRequests || [],

View File

@ -19,6 +19,7 @@ export function toViewModel(config: ContainerDetailsJSON): Values {
value,
})
),
securityOpt: config.HostConfig?.SecurityOpt || [],
gpu: gpuFieldsetUtils.toViewModel(config.HostConfig?.DeviceRequests || []),
sharedMemorySize: toViewModelMemory(config.HostConfig?.ShmSize),
resources: {
@ -38,6 +39,7 @@ export function getDefaultViewModel(): Values {
},
devices: [],
sysctls: [],
securityOpt: [],
sharedMemorySize: 64,
gpu: gpuFieldsetUtils.getDefaultViewModel(),
resources: {

View File

@ -6,6 +6,7 @@ import { resourcesValidation } from './ResourcesFieldset';
import { Values } from './ResourcesTab';
import { runtimeValidation } from './RuntimeSection';
import { sysctlsValidation } from './SysctlsField';
import { securityOptValidation } from './SecurityOptField';
export function validation({
maxMemory,
@ -18,6 +19,7 @@ export function validation({
runtime: runtimeValidation(),
devices: devicesValidation(),
sysctls: sysctlsValidation(),
securityOpt: securityOptValidation(),
sharedMemorySize: number().min(0).default(0),
gpu: gpuFieldsetUtils.validation(),
resources: resourcesValidation({ maxMemory, maxCpu }),

View File

@ -108,6 +108,8 @@ export interface EnvironmentSecuritySettings {
allowContainerCapabilitiesForRegularUsers: boolean;
// Whether non-administrator should be able to use sysctl settings
allowSysctlSettingForRegularUsers: boolean;
// Whether non-administrator should be able to use security-opt settings
allowSecurityOptForRegularUsers: boolean;
// Whether host management features are enabled
enableHostManagementFeatures: boolean;
}