samba: init permissions improvements, fix guest access

This commit is contained in:
hayzamjs
2026-04-10 17:25:09 +05:30
parent 7834fdace3
commit 3d96da5873
20 changed files with 6011 additions and 812 deletions
@@ -5,7 +5,7 @@ description: Managing samba shares on Sylve
## Pre-Requisites
You should have atleast 1 Group with the user you want to share the folder with. You can create a group and add users to it by following the Groups and Users guide.
You should have local users and/or groups ready before creating a share. You can manage both from the Authentication section.
## Preface
@@ -26,17 +26,23 @@ Let's go through the fields one by one:
- **Dataset**: The dataset you want to share, this is the path that will be shared over samba. You can create one in the ZFS/Filesystem section if you don't have one already.
- **Read-Only Groups**: The groups that will have read-only access to the share, you can select multiple groups here. If a user belongs to multiple groups and one of them is selected here, they will have read-only access to the share.
- **Read Users**: Users that get read access.
- **Writeable Groups**: The groups that will have read-write access to the share, you can select multiple groups here. If a user belongs to multiple groups and one of them is selected here, they will have read-write access to the share.
- **Write Users**: Users that get write access.
- **Read Groups**: Groups that get read access.
- **Write Groups**: Groups that get write access.
Sylve uses **write-wins** normalization. If the same user/group is selected in both read and write lists, it is treated as write access.
- **Create Mask**: The permissions that will be set on files created in the share, this is a standard unix permission mask. The default value is `0664` which means that the owner and group will have read and write permissions, while others will have read-only permissions.
- **Directory Mask**: The permissions that will be set on directories created in the share, this is a standard unix permission mask. The default value is `0775` which means that the owner and group will have read, write and execute permissions, while others will have read and execute permissions.
- **Directory Mask**: The permissions that will be set on directories created in the share, this is a standard unix permission mask. The default value is `2775` which means that the owner and group will have read, write and execute permissions, while others will have read and execute permissions.
- **Guest Access**: Whether to allow guest access to the share, if enabled users will be able to connect to the share without providing a username and password. This is disabled by default.
- **Guest Only**: Whether to make this a guest-only share. If enabled, authenticated user/group access is disabled for that share.
- **Read-Only**: Whether to make the share read-only, if enabled users will only be able to read files from the share, but not write to it. This is disabled by default.
- **Guest Writeable**: Only shown in guest-only mode. Controls whether guests can write or read-only.
## Editing a Samba Share
@@ -54,6 +60,10 @@ Never ever expose samba directly to the internet, if you want to access your sam
In this demonstration we'll be accessing the samba share from a macOS client over tailcale, but the process is similar for other operating systems as well.
:::note
Modern Windows versions may block insecure SMB guest logons by default. If guest-only shares fail from Windows clients, check the client-side SMB guest logon policy.
:::
![Opening the Samba Share](./conn-1.png)
In the "Connect to Server" dialog, enter the address of your Sylve server in the following format:
@@ -66,4 +76,4 @@ smb://<IP_ADDRESS_OR_HOSTNAME>/<SHARE_NAME>
After the authentication is successful, you should see the contents of the share in the Finder.
![Contents of the Samba Share](./conn-3.png)
![Contents of the Samba Share](./conn-3.png)
+764 -104
View File
File diff suppressed because it is too large Load Diff
+764 -104
View File
File diff suppressed because it is too large Load Diff
+506 -73
View File
@@ -179,19 +179,6 @@ definitions:
status:
type: string
type: object
github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_samba_SambaShare:
properties:
data:
items:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare'
type: array
error:
type: string
message:
type: string
status:
type: string
type: object
? github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_utilities_CloudInitTemplate
: properties:
data:
@@ -283,6 +270,19 @@ definitions:
status:
type: string
type: object
? github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_BootstrapEntry
: properties:
data:
items:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry'
type: array
error:
type: string
message:
type: string
status:
type: string
type: object
? github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_SimpleList
: properties:
data:
@@ -361,6 +361,19 @@ definitions:
status:
type: string
type: object
github_com_alchemillahq_sylve_internal.APIResponse-array_internal_handlers_samba_SambaShareResponse:
properties:
data:
items:
$ref: '#/definitions/internal_handlers_samba.SambaShareResponse'
type: array
error:
type: string
message:
type: string
status:
type: string
type: object
github_com_alchemillahq_sylve_internal.APIResponse-github_com_alchemillahq_sylve_internal_db_models_BasicSettings:
properties:
data:
@@ -628,6 +641,9 @@ definitions:
- samba-server
- virtualization
- wol-server
- firewall
- wireguard
- iscsi
type: string
x-enum-varnames:
- DHCPServer
@@ -635,6 +651,9 @@ definitions:
- SambaServer
- Virtualization
- WoLServer
- Firewall
- WireGuard
- ISCSI
github_com_alchemillahq_sylve_internal_db_models.BasicSettings:
properties:
id:
@@ -705,24 +724,44 @@ definitions:
type: boolean
createdAt:
type: string
disablePassword:
type: boolean
doasEnabled:
type: boolean
email:
type: string
fullName:
type: string
groups:
items:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group'
type: array
homeDirPerms:
type: integer
homeDirectory:
type: string
id:
type: integer
lastLoginTime:
type: string
locked:
type: boolean
notes:
type: string
primaryGroupId:
type: integer
shell:
type: string
sshPublicKey:
type: string
tokens:
items:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Token'
type: array
totp:
type: string
uid:
type: integer
updatedAt:
type: string
username:
@@ -1158,6 +1197,8 @@ definitions:
type: object
github_com_alchemillahq_sylve_internal_db_models_network.Object:
properties:
autoUpdate:
type: boolean
createdAt:
type: string
description:
@@ -1173,14 +1214,25 @@ definitions:
isUsedBy:
description: '"", "dhcp" for now'
type: string
lastRefreshAt:
type: string
lastRefreshError:
type: string
name:
type: string
refreshIntervalSeconds:
type: integer
resolutionChecksum:
type: string
resolutions:
items:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_network.ObjectResolution'
type: array
sourceChecksum:
type: string
type:
description: '"Host", "Mac", "Network", "Port", "Country", "List"'
description: '"Host", "Mac", "Network", "Port", "Country", "List", "FQDN",
"DUID"'
type: string
updatedAt:
type: string
@@ -1208,7 +1260,9 @@ definitions:
objectId:
type: integer
resolvedIp:
description: actual IP resolved only in the case of FQDN
description: deprecated mirror for resolved IP entries
type: string
resolvedValue:
type: string
updatedAt:
type: string
@@ -1297,6 +1351,8 @@ definitions:
type: object
github_com_alchemillahq_sylve_internal_db_models_samba.SambaSettings:
properties:
appleExtensions:
type: boolean
bindInterfacesOnly:
type: boolean
id:
@@ -1310,37 +1366,6 @@ definitions:
workgroup:
type: string
type: object
github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare:
properties:
createMask:
type: string
createdAt:
type: string
dataset:
type: string
directoryMask:
type: string
guestOk:
type: boolean
id:
type: integer
name:
type: string
path:
type: string
readOnly:
type: boolean
readOnlyGroups:
items:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group'
type: array
updatedAt:
type: string
writeableGroups:
items:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group'
type: array
type: object
github_com_alchemillahq_sylve_internal_db_models_utilities.CloudInitTemplate:
properties:
createdAt:
@@ -1516,6 +1541,8 @@ definitions:
type: boolean
apic:
type: boolean
bootRom:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM'
cloudInitData:
type: string
cloudInitMetaData:
@@ -1536,6 +1563,10 @@ definitions:
type: string
description:
type: string
extraBhyveOptions:
items:
type: string
type: array
id:
type: integer
ignoreUMSR:
@@ -1584,6 +1615,8 @@ definitions:
type: boolean
updatedAt:
type: string
vncBind:
type: string
vncEnabled:
type: boolean
vncPassword:
@@ -1597,6 +1630,14 @@ definitions:
wol:
type: boolean
type: object
github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM:
enum:
- uefi
- none
type: string
x-enum-varnames:
- VMBootROMUEFI
- VMBootROMNone
github_com_alchemillahq_sylve_internal_db_models_vm.VMCPUPinning:
properties:
hostCpu:
@@ -1983,6 +2024,48 @@ definitions:
- name
- switchName
type: object
github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry:
properties:
dataset:
type: string
error:
type: string
exists:
type: boolean
label:
type: string
major:
type: integer
minor:
type: integer
mountPoint:
type: string
name:
type: string
phase:
type: string
pool:
type: string
status:
type: string
type:
type: string
type: object
github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest:
properties:
major:
type: integer
minor:
type: integer
pool:
type: string
type:
type: string
required:
- major
- pool
- type
type: object
github_com_alchemillahq_sylve_internal_interfaces_services_jail.CreateJailRequest:
properties:
additionalOptions:
@@ -1993,6 +2076,8 @@ definitions:
type: array
base:
type: string
bootstrapName:
type: string
cleanEnvironment:
type: boolean
cores:
@@ -2157,6 +2242,8 @@ definitions:
type: boolean
apic:
type: boolean
bootRom:
type: string
cloudInit:
type: boolean
cloudInitData:
@@ -2177,6 +2264,10 @@ definitions:
type: integer
description:
type: string
extraBhyveOptions:
items:
type: string
type: array
ignoreUMSR:
type: boolean
iso:
@@ -2217,6 +2308,10 @@ definitions:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.TimeOffset'
tpmEmulation:
type: boolean
vncBind:
type: string
vncEnabled:
type: boolean
vncPassword:
type: string
vncPort:
@@ -2265,6 +2360,8 @@ definitions:
type: object
github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.ModifyVNCRequest:
properties:
vncBind:
type: string
vncEnabled:
type: boolean
vncPassword:
@@ -2658,6 +2755,8 @@ definitions:
type: array
datasetType:
$ref: '#/definitions/gzfs.DatasetType'
nameFilter:
type: string
page:
type: integer
search:
@@ -3230,19 +3329,42 @@ definitions:
properties:
admin:
type: boolean
auxGroupIds:
items:
type: integer
type: array
disablePassword:
type: boolean
doasEnabled:
type: boolean
email:
type: string
password:
maxLength: 128
minLength: 3
fullName:
type: string
homeDirPerms:
type: integer
homeDirectory:
type: string
locked:
type: boolean
newPrimaryGroup:
type: boolean
password:
type: string
primaryGroupId:
type: integer
shell:
type: string
sshPublicKey:
type: string
uid:
type: integer
username:
maxLength: 128
minLength: 3
type: string
required:
- admin
- password
- username
type: object
internal_handlers_auth.LoginRequest:
@@ -3471,6 +3593,15 @@ definitions:
ipv6:
type: boolean
type: object
internal_handlers_network.BulkDeleteNetworkObjectsRequest:
properties:
ids:
items:
type: integer
type: array
required:
- ids
type: object
internal_handlers_network.CreateManualSwitchRequest:
properties:
bridge:
@@ -3598,23 +3729,21 @@ definitions:
type: string
directoryMask:
type: string
guestOk:
type: boolean
guest:
$ref: '#/definitions/internal_handlers_samba.SambaGuestRequest'
name:
type: string
readOnly:
permissions:
$ref: '#/definitions/internal_handlers_samba.SambaPermissionsRequest'
timeMachine:
type: boolean
readOnlyGroups:
items:
type: string
type: array
writeableGroups:
items:
type: string
type: array
timeMachineMaxSize:
type: integer
type: object
internal_handlers_samba.SambaConfigRequest:
properties:
appleExtensions:
type: boolean
bindInterfacesOnly:
type: boolean
interfaces:
@@ -3626,6 +3755,95 @@ definitions:
workgroup:
type: string
type: object
internal_handlers_samba.SambaGuestRequest:
properties:
enabled:
type: boolean
writeable:
type: boolean
type: object
internal_handlers_samba.SambaGuestResponse:
properties:
enabled:
type: boolean
writeable:
type: boolean
type: object
internal_handlers_samba.SambaPermissionsRequest:
properties:
read:
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest'
write:
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest'
type: object
internal_handlers_samba.SambaPermissionsResponse:
properties:
read:
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalSetResponse'
write:
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalSetResponse'
type: object
internal_handlers_samba.SambaPrincipalGroupResponse:
properties:
id:
type: integer
name:
type: string
type: object
internal_handlers_samba.SambaPrincipalIDsRequest:
properties:
groupIds:
items:
type: integer
type: array
userIds:
items:
type: integer
type: array
type: object
internal_handlers_samba.SambaPrincipalSetResponse:
properties:
groups:
items:
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalGroupResponse'
type: array
users:
items:
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalUserResponse'
type: array
type: object
internal_handlers_samba.SambaPrincipalUserResponse:
properties:
id:
type: integer
username:
type: string
type: object
internal_handlers_samba.SambaShareResponse:
properties:
createMask:
type: string
createdAt:
type: string
dataset:
type: string
directoryMask:
type: string
guest:
$ref: '#/definitions/internal_handlers_samba.SambaGuestResponse'
id:
type: integer
name:
type: string
permissions:
$ref: '#/definitions/internal_handlers_samba.SambaPermissionsResponse'
timeMachine:
type: boolean
timeMachineMaxSize:
type: integer
updatedAt:
type: string
type: object
internal_handlers_samba.UpdateSambaShareRequest:
properties:
createMask:
@@ -3634,22 +3852,18 @@ definitions:
type: string
directoryMask:
type: string
guestOk:
type: boolean
guest:
$ref: '#/definitions/internal_handlers_samba.SambaGuestRequest'
id:
type: integer
name:
type: string
readOnly:
permissions:
$ref: '#/definitions/internal_handlers_samba.SambaPermissionsRequest'
timeMachine:
type: boolean
readOnlyGroups:
items:
type: string
type: array
writeableGroups:
items:
type: string
type: array
timeMachineMaxSize:
type: integer
type: object
internal_handlers_system.AddFileOrFolderRequest:
properties:
@@ -3709,6 +3923,11 @@ definitions:
startAtBoot:
type: boolean
type: object
internal_handlers_vm.ModifyBootROMRequest:
properties:
bootRom:
type: string
type: object
internal_handlers_vm.ModifyClockRequest:
properties:
timeOffset:
@@ -3723,6 +3942,13 @@ definitions:
networkConfig:
type: string
type: object
internal_handlers_vm.ModifyExtraBhyveOptionsRequest:
properties:
extraBhyveOptions:
items:
type: string
type: array
type: object
internal_handlers_vm.ModifyIgnoreUMSRsRequest:
properties:
ignoreUMSRs:
@@ -5496,6 +5722,115 @@ paths:
summary: Perform Jail Action
tags:
- Jail
/jail/bootstrap:
delete:
consumes:
- application/json
description: Destroy a completed pkgbase bootstrap dataset and remove its DB
record
parameters:
- description: Pool name
in: query
name: pool
required: true
type: string
- description: Bootstrap name (e.g. 15-0-Base)
in: query
name: name
required: true
type: string
produces:
- application/json
responses:
"200":
description: Success
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"400":
description: Bad Request
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"409":
description: Conflict
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
security:
- BearerAuth: []
summary: Delete bootstrap
tags:
- Jail
post:
consumes:
- application/json
description: Start a pkgbase bootstrap for the given pool, version, and type.
Returns immediately; bootstrap runs asynchronously.
parameters:
- description: Bootstrap Request
in: body
name: request
required: true
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest'
produces:
- application/json
responses:
"202":
description: Accepted
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"400":
description: Bad Request
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"409":
description: Conflict
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
security:
- BearerAuth: []
summary: Create bootstrap
tags:
- Jail
/jail/bootstraps:
get:
consumes:
- application/json
description: List all supported pkgbase bootstrap entries for a pool, with their
current install status
parameters:
- description: Pool name
in: query
name: pool
required: true
type: string
produces:
- application/json
responses:
"200":
description: Success
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_BootstrapEntry'
"400":
description: Bad Request
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
security:
- BearerAuth: []
summary: List bootstraps
tags:
- Jail
/jail/cpu:
put:
consumes:
@@ -6492,6 +6827,39 @@ paths:
summary: Edit Network Object
tags:
- Network
/network/object/bulk-delete:
post:
consumes:
- application/json
description: Delete multiple network objects by their IDs; fails if any object
is in use
parameters:
- description: Bulk Delete Request
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_handlers_network.BulkDeleteNetworkObjectsRequest'
produces:
- application/json
responses:
"200":
description: Objects deleted successfully
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"400":
description: Invalid request
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"500":
description: Internal server error
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
security:
- BearerAuth: []
summary: Bulk Delete Network Objects
tags:
- Network
/network/switch:
get:
consumes:
@@ -6732,6 +7100,38 @@ paths:
summary: Modify Boot Order of a Virtual Machine
tags:
- VM
/options/boot-rom/:rid:
put:
consumes:
- application/json
description: Modify the Boot ROM mode of a virtual machine
parameters:
- description: Modify Boot ROM Request
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_handlers_vm.ModifyBootROMRequest'
produces:
- application/json
responses:
"200":
description: Success
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"400":
description: Bad Request
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
security:
- BearerAuth: []
summary: Modify Boot ROM of a Virtual Machine
tags:
- VM
/options/clock/:rid:
put:
consumes:
@@ -6828,6 +7228,39 @@ paths:
summary: Modify DevFS rules of a Jail
tags:
- Jail
/options/extra-bhyve-options/:rid:
put:
consumes:
- application/json
description: Modify custom bhyve arguments (one argument per line) of a virtual
machine
parameters:
- description: Modify Extra Bhyve Options Request
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_handlers_vm.ModifyExtraBhyveOptionsRequest'
produces:
- application/json
responses:
"200":
description: Success
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"400":
description: Bad Request
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
security:
- BearerAuth: []
summary: Modify Extra Bhyve Options of a Virtual Machine
tags:
- VM
/options/fstab/:rid:
put:
consumes:
@@ -7273,7 +7706,7 @@ paths:
"200":
description: Success
schema:
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_samba_SambaShare'
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-array_internal_handlers_samba_SambaShareResponse'
"500":
description: Internal Server Error
schema:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+86
View File
@@ -30,6 +30,7 @@ func Fixups(db *gorm.DB) error {
cleanupInvalidAuditUserIDs(db)
cleanupLegacyDevdEventsTable(db)
dropSambaSharePathUniqueIndex(db)
normalizeSambaSharePermissionOverlaps(db)
backfillFirewallRuleVisibilityDefaults(db)
backfillUserSystemData(db)
@@ -328,6 +329,91 @@ func dropSambaSharePathUniqueIndex(db *gorm.DB) {
})
}
func normalizeSambaSharePermissionOverlaps(db *gorm.DB) {
const name = "normalize_samba_share_permission_overlaps_1"
var count int64
if err := db.
Table("migrations").
Where("name = ?", name).
Count(&count).Error; err != nil {
logger.L.Err(err).Msg("migration check failed for normalize_samba_share_permission_overlaps")
return
}
if count > 0 {
return
}
if !db.Migrator().HasTable(&sambaModels.SambaShare{}) {
db.Table("migrations").Create(map[string]any{"name": name})
return
}
var shares []sambaModels.SambaShare
if err := db.
Preload("ReadOnlyUsers").
Preload("WriteableUsers").
Preload("ReadOnlyGroups").
Preload("WriteableGroups").
Find(&shares).Error; err != nil {
logger.L.Err(err).Msg("failed loading samba shares for permission overlap normalization")
return
}
for i := range shares {
share := shares[i]
writeUserIDs := make(map[uint]struct{}, len(share.WriteableUsers))
for _, user := range share.WriteableUsers {
writeUserIDs[user.ID] = struct{}{}
}
writeGroupIDs := make(map[uint]struct{}, len(share.WriteableGroups))
for _, group := range share.WriteableGroups {
writeGroupIDs[group.ID] = struct{}{}
}
filteredReadUsers := make([]authModels.User, 0, len(share.ReadOnlyUsers))
userChanged := false
for _, user := range share.ReadOnlyUsers {
if _, exists := writeUserIDs[user.ID]; exists {
userChanged = true
continue
}
filteredReadUsers = append(filteredReadUsers, user)
}
filteredReadGroups := make([]authModels.Group, 0, len(share.ReadOnlyGroups))
groupChanged := false
for _, group := range share.ReadOnlyGroups {
if _, exists := writeGroupIDs[group.ID]; exists {
groupChanged = true
continue
}
filteredReadGroups = append(filteredReadGroups, group)
}
if userChanged {
if err := db.Model(&share).Association("ReadOnlyUsers").Replace(filteredReadUsers); err != nil {
logger.L.Err(err).Int("share_id", share.ID).Msg("failed normalizing samba share user permission overlap")
return
}
}
if groupChanged {
if err := db.Model(&share).Association("ReadOnlyGroups").Replace(filteredReadGroups); err != nil {
logger.L.Err(err).Int("share_id", share.ID).Msg("failed normalizing samba share group permission overlap")
return
}
}
}
db.Table("migrations").Create(map[string]any{
"name": name,
})
}
func backfillFirewallRuleVisibilityDefaults(db *gorm.DB) {
const name = "backfill_firewall_rule_visibility_defaults_1"
+2
View File
@@ -19,6 +19,8 @@ type SambaShare struct {
Name string `json:"name" gorm:"uniqueIndex"`
Dataset string `json:"dataset" gorm:"uniqueIndex"`
Path string `json:"path"`
ReadOnlyUsers []models.User `json:"readOnlyUsers" gorm:"many2many:samba_share_read_only_users;"`
WriteableUsers []models.User `json:"writeableUsers" gorm:"many2many:samba_share_writeable_users;"`
ReadOnlyGroups []models.Group `json:"readOnlyGroups" gorm:"many2many:samba_share_read_only_groups;"`
WriteableGroups []models.Group `json:"writeableGroups" gorm:"many2many:samba_share_writeable_groups;"`
CreateMask string `json:"createMask" gorm:"default:'0664'"`
+197 -61
View File
@@ -9,41 +9,192 @@
package sambaHandlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/alchemillahq/sylve/internal"
authModels "github.com/alchemillahq/sylve/internal/db/models"
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
"github.com/alchemillahq/sylve/internal/services/samba"
"github.com/gin-gonic/gin"
)
type SambaPrincipalIDsRequest struct {
UserIDs []uint `json:"userIds"`
GroupIDs []uint `json:"groupIds"`
}
type SambaPermissionsRequest struct {
Read SambaPrincipalIDsRequest `json:"read"`
Write SambaPrincipalIDsRequest `json:"write"`
}
type SambaGuestRequest struct {
Enabled bool `json:"enabled"`
Writeable bool `json:"writeable"`
}
type CreateSambaShareRequest struct {
Name string `json:"name"`
Dataset string `json:"dataset"`
ReadOnlyGroups []string `json:"readOnlyGroups"`
WriteableGroups []string `json:"writeableGroups"`
CreateMask string `json:"createMask"`
DirectoryMask string `json:"directoryMask"`
GuestOk *bool `json:"guestOk"`
ReadOnly *bool `json:"readOnly"`
TimeMachine *bool `json:"timeMachine"`
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
Name string `json:"name"`
Dataset string `json:"dataset"`
Permissions SambaPermissionsRequest `json:"permissions"`
Guest SambaGuestRequest `json:"guest"`
CreateMask string `json:"createMask"`
DirectoryMask string `json:"directoryMask"`
TimeMachine *bool `json:"timeMachine"`
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
}
type UpdateSambaShareRequest struct {
ID uint `json:"id"`
Name string `json:"name"`
Dataset string `json:"dataset"`
ReadOnlyGroups []string `json:"readOnlyGroups"`
WriteableGroups []string `json:"writeableGroups"`
CreateMask string `json:"createMask"`
DirectoryMask string `json:"directoryMask"`
GuestOk *bool `json:"guestOk"`
ReadOnly *bool `json:"readOnly"`
TimeMachine *bool `json:"timeMachine"`
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
ID uint `json:"id"`
Name string `json:"name"`
Dataset string `json:"dataset"`
Permissions SambaPermissionsRequest `json:"permissions"`
Guest SambaGuestRequest `json:"guest"`
CreateMask string `json:"createMask"`
DirectoryMask string `json:"directoryMask"`
TimeMachine *bool `json:"timeMachine"`
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
}
type SambaPrincipalUserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
}
type SambaPrincipalGroupResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type SambaPrincipalSetResponse struct {
Users []SambaPrincipalUserResponse `json:"users"`
Groups []SambaPrincipalGroupResponse `json:"groups"`
}
type SambaPermissionsResponse struct {
Read SambaPrincipalSetResponse `json:"read"`
Write SambaPrincipalSetResponse `json:"write"`
}
type SambaGuestResponse struct {
Enabled bool `json:"enabled"`
Writeable bool `json:"writeable"`
}
type SambaShareResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Dataset string `json:"dataset"`
Permissions SambaPermissionsResponse `json:"permissions"`
Guest SambaGuestResponse `json:"guest"`
CreateMask string `json:"createMask"`
DirectoryMask string `json:"directoryMask"`
TimeMachine bool `json:"timeMachine"`
TimeMachineMaxSize uint64 `json:"timeMachineMaxSize"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func strictJSONBind(c *gin.Context, dst any) error {
raw, err := c.GetRawData()
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("invalid_json_payload")
}
return err
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))
return nil
}
func mapUsers(users []authModels.User) []SambaPrincipalUserResponse {
mapped := make([]SambaPrincipalUserResponse, 0, len(users))
for _, user := range users {
mapped = append(mapped, SambaPrincipalUserResponse{ID: user.ID, Username: user.Username})
}
return mapped
}
func mapGroups(groups []authModels.Group) []SambaPrincipalGroupResponse {
mapped := make([]SambaPrincipalGroupResponse, 0, len(groups))
for _, group := range groups {
mapped = append(mapped, SambaPrincipalGroupResponse{ID: group.ID, Name: group.Name})
}
return mapped
}
func mapShareResponse(share sambaModels.SambaShare) SambaShareResponse {
guestWriteable := share.GuestOk && !share.ReadOnly
return SambaShareResponse{
ID: share.ID,
Name: share.Name,
Dataset: share.Dataset,
Permissions: SambaPermissionsResponse{
Read: SambaPrincipalSetResponse{
Users: mapUsers(share.ReadOnlyUsers),
Groups: mapGroups(share.ReadOnlyGroups),
},
Write: SambaPrincipalSetResponse{
Users: mapUsers(share.WriteableUsers),
Groups: mapGroups(share.WriteableGroups),
},
},
Guest: SambaGuestResponse{
Enabled: share.GuestOk,
Writeable: guestWriteable,
},
CreateMask: share.CreateMask,
DirectoryMask: share.DirectoryMask,
TimeMachine: share.TimeMachine,
TimeMachineMaxSize: share.TimeMachineMaxSize,
CreatedAt: share.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: share.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
func sambaShareServiceErrorStatus(err error) int {
if err == nil {
return http.StatusOK
}
msg := err.Error()
switch {
case msg == "share_with_name_exists", msg == "share_with_dataset_exists":
return http.StatusConflict
case strings.HasPrefix(msg, "share_not_found"):
return http.StatusNotFound
case msg == "guest_only_share_cannot_have_principals",
msg == "no_principals_selected_and_guests_not_allowed",
msg == "dataset_not_found",
msg == "dataset_not_mounted",
strings.HasPrefix(msg, "user_not_found:"),
strings.HasPrefix(msg, "group_not_found:"),
strings.Contains(msg, "dataset_not_filesystem:"):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
// @Summary Get Samba Shares
@@ -51,7 +202,7 @@ type UpdateSambaShareRequest struct {
// @Tags Samba
// @Accept json
// @Produce json
// @Success 200 {object} internal.APIResponse[[]sambaModels.SambaShare] "Success"
// @Success 200 {object} internal.APIResponse[[]SambaShareResponse] "Success"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /samba/shares [get]
func GetShares(smbService *samba.Service) gin.HandlerFunc {
@@ -67,11 +218,16 @@ func GetShares(smbService *samba.Service) gin.HandlerFunc {
return
}
c.JSON(http.StatusOK, internal.APIResponse[[]sambaModels.SambaShare]{
mapped := make([]SambaShareResponse, 0, len(shares))
for _, share := range shares {
mapped = append(mapped, mapShareResponse(share))
}
c.JSON(http.StatusOK, internal.APIResponse[[]SambaShareResponse]{
Status: "success",
Message: "shares_retrieved",
Error: "",
Data: shares,
Data: mapped,
})
}
}
@@ -89,7 +245,7 @@ func GetShares(smbService *samba.Service) gin.HandlerFunc {
func CreateShare(smbService *samba.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var request CreateSambaShareRequest
if err := c.ShouldBindJSON(&request); err != nil {
if err := strictJSONBind(c, &request); err != nil {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
@@ -99,18 +255,6 @@ func CreateShare(smbService *samba.Service) gin.HandlerFunc {
return
}
guestOk := false
if request.GuestOk != nil {
guestOk = *request.GuestOk
}
readOnly := false
if request.ReadOnly != nil {
readOnly = *request.ReadOnly
}
timeMachine := false
if request.TimeMachine != nil {
timeMachine = *request.TimeMachine
@@ -126,16 +270,18 @@ func CreateShare(smbService *samba.Service) gin.HandlerFunc {
ctx,
request.Name,
request.Dataset,
request.ReadOnlyGroups,
request.WriteableGroups,
request.Permissions.Read.UserIDs,
request.Permissions.Write.UserIDs,
request.Permissions.Read.GroupIDs,
request.Permissions.Write.GroupIDs,
request.Guest.Enabled,
request.Guest.Writeable,
request.CreateMask,
request.DirectoryMask,
guestOk,
readOnly,
timeMachine,
timeMachineMaxSize,
); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
c.JSON(sambaShareServiceErrorStatus(err), internal.APIResponse[any]{
Status: "error",
Message: "failed_to_create_share",
Error: err.Error(),
@@ -166,7 +312,7 @@ func CreateShare(smbService *samba.Service) gin.HandlerFunc {
func UpdateShare(smbService *samba.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var request UpdateSambaShareRequest
if err := c.ShouldBindJSON(&request); err != nil {
if err := strictJSONBind(c, &request); err != nil {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
@@ -176,18 +322,6 @@ func UpdateShare(smbService *samba.Service) gin.HandlerFunc {
return
}
guestOk := false
if request.GuestOk != nil {
guestOk = *request.GuestOk
}
readOnly := false
if request.ReadOnly != nil {
readOnly = *request.ReadOnly
}
timeMachine := false
if request.TimeMachine != nil {
timeMachine = *request.TimeMachine
@@ -204,16 +338,18 @@ func UpdateShare(smbService *samba.Service) gin.HandlerFunc {
request.ID,
request.Name,
request.Dataset,
request.ReadOnlyGroups,
request.WriteableGroups,
request.Permissions.Read.UserIDs,
request.Permissions.Write.UserIDs,
request.Permissions.Read.GroupIDs,
request.Permissions.Write.GroupIDs,
request.Guest.Enabled,
request.Guest.Writeable,
request.CreateMask,
request.DirectoryMask,
guestOk,
readOnly,
timeMachine,
timeMachineMaxSize,
); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
c.JSON(sambaShareServiceErrorStatus(err), internal.APIResponse[any]{
Status: "error",
Message: "failed_to_update_share",
Error: err.Error(),
@@ -258,7 +394,7 @@ func DeleteShare(smbService *samba.Service) gin.HandlerFunc {
ctx := c.Request.Context()
if err := smbService.DeleteShare(ctx, uint(idInt)); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
c.JSON(sambaShareServiceErrorStatus(err), internal.APIResponse[any]{
Status: "error",
Message: "failed_to_delete_share",
Error: err.Error(),
+277
View File
@@ -0,0 +1,277 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package sambaHandlers
import (
"encoding/json"
"net/http"
"strings"
"testing"
authModels "github.com/alchemillahq/sylve/internal/db/models"
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
"github.com/alchemillahq/sylve/internal/services/samba"
"github.com/gin-gonic/gin"
)
func newSambaSharesRouter(smbService *samba.Service) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/samba/shares", GetShares(smbService))
r.POST("/samba/shares", CreateShare(smbService))
r.PUT("/samba/shares", UpdateShare(smbService))
return r
}
func TestCreateShareRejectsLegacyV1PayloadFields(t *testing.T) {
router := newSambaSharesRouter(&samba.Service{})
body := []byte(`{
"name":"legacy-share",
"dataset":"dataset-guid",
"readOnlyGroups":["staff"],
"writeableGroups":["admins"],
"guestOk":false
}`)
rr := performSambaJSONRequest(t, router, http.MethodPost, "/samba/shares", body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d body=%s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Message != "invalid_request" {
t.Fatalf("expected invalid_request, got %q", resp.Message)
}
if !strings.Contains(resp.Error, "unknown field") {
t.Fatalf("expected strict unknown field error, got %q", resp.Error)
}
}
func TestUpdateShareRejectsLegacyV1PayloadFields(t *testing.T) {
router := newSambaSharesRouter(&samba.Service{})
body := []byte(`{
"id":1,
"name":"legacy-share",
"dataset":"dataset-guid",
"readOnlyGroups":["staff"],
"writeableGroups":["admins"],
"guestOk":false
}`)
rr := performSambaJSONRequest(t, router, http.MethodPut, "/samba/shares", body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d body=%s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Message != "invalid_request" {
t.Fatalf("expected invalid_request, got %q", resp.Message)
}
if !strings.Contains(resp.Error, "unknown field") {
t.Fatalf("expected strict unknown field error, got %q", resp.Error)
}
}
func TestGetSharesReturnsExpandedV2Permissions(t *testing.T) {
db := newSambaHandlerTestDB(
t,
&authModels.User{},
&authModels.Group{},
&sambaModels.SambaShare{},
)
readUser := authModels.User{Username: "alice"}
writeUser := authModels.User{Username: "bob"}
readGroup := authModels.Group{Name: "staff_ro"}
writeGroup := authModels.Group{Name: "staff_rw"}
if err := db.Create(&readUser).Error; err != nil {
t.Fatalf("failed creating read user: %v", err)
}
if err := db.Create(&writeUser).Error; err != nil {
t.Fatalf("failed creating write user: %v", err)
}
if err := db.Create(&readGroup).Error; err != nil {
t.Fatalf("failed creating read group: %v", err)
}
if err := db.Create(&writeGroup).Error; err != nil {
t.Fatalf("failed creating write group: %v", err)
}
share := sambaModels.SambaShare{
Name: "secure",
Dataset: "dataset-guid",
GuestOk: false,
ReadOnly: true,
CreateMask: "0664",
DirectoryMask: "2775",
}
if err := db.Create(&share).Error; err != nil {
t.Fatalf("failed creating share: %v", err)
}
if err := db.Model(&share).Association("ReadOnlyUsers").Append(&readUser); err != nil {
t.Fatalf("failed appending read user: %v", err)
}
if err := db.Model(&share).Association("WriteableUsers").Append(&writeUser); err != nil {
t.Fatalf("failed appending write user: %v", err)
}
if err := db.Model(&share).Association("ReadOnlyGroups").Append(&readGroup); err != nil {
t.Fatalf("failed appending read group: %v", err)
}
if err := db.Model(&share).Association("WriteableGroups").Append(&writeGroup); err != nil {
t.Fatalf("failed appending write group: %v", err)
}
svc := &samba.Service{DB: db}
router := newSambaSharesRouter(svc)
rr := performSambaJSONRequest(t, router, http.MethodGet, "/samba/shares", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d body=%s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[[]SambaShareResponse]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected one share in response, got %d", len(resp.Data))
}
got := resp.Data[0]
if got.Name != "secure" || got.Dataset != "dataset-guid" {
t.Fatalf("unexpected share identity payload: %+v", got)
}
if got.Guest.Enabled {
t.Fatalf("expected authenticated share to have guest.enabled=false")
}
if got.Guest.Writeable {
t.Fatalf("expected authenticated share to have guest.writeable=false")
}
if len(got.Permissions.Read.Users) != 1 || got.Permissions.Read.Users[0].Username != "alice" {
t.Fatalf("unexpected read users payload: %+v", got.Permissions.Read.Users)
}
if len(got.Permissions.Write.Users) != 1 || got.Permissions.Write.Users[0].Username != "bob" {
t.Fatalf("unexpected write users payload: %+v", got.Permissions.Write.Users)
}
if len(got.Permissions.Read.Groups) != 1 || got.Permissions.Read.Groups[0].Name != "staff_ro" {
t.Fatalf("unexpected read groups payload: %+v", got.Permissions.Read.Groups)
}
if len(got.Permissions.Write.Groups) != 1 || got.Permissions.Write.Groups[0].Name != "staff_rw" {
t.Fatalf("unexpected write groups payload: %+v", got.Permissions.Write.Groups)
}
}
func TestCreateShareReturnsConflictForDuplicateName(t *testing.T) {
db := newSambaHandlerTestDB(t, &sambaModels.SambaShare{})
if err := db.Create(&sambaModels.SambaShare{
Name: "dupe",
Dataset: "dataset-guid-1",
CreateMask: "0664",
DirectoryMask: "2775",
}).Error; err != nil {
t.Fatalf("failed to seed share: %v", err)
}
svc := &samba.Service{DB: db}
router := newSambaSharesRouter(svc)
body := []byte(`{
"name":"dupe",
"dataset":"dataset-guid-2",
"permissions":{"read":{"userIds":[],"groupIds":[]},"write":{"userIds":[],"groupIds":[]}},
"guest":{"enabled":true,"writeable":false},
"createMask":"0664",
"directoryMask":"2775"
}`)
rr := performSambaJSONRequest(t, router, http.MethodPost, "/samba/shares", body)
if rr.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d body=%s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Error != "share_with_name_exists" {
t.Fatalf("expected share_with_name_exists error, got %q", resp.Error)
}
}
func TestCreateShareReturnsBadRequestForInvalidPrincipalMode(t *testing.T) {
db := newSambaHandlerTestDB(t, &sambaModels.SambaShare{})
svc := &samba.Service{DB: db}
router := newSambaSharesRouter(svc)
body := []byte(`{
"name":"invalid",
"dataset":"dataset-guid-2",
"permissions":{"read":{"userIds":[],"groupIds":[]},"write":{"userIds":[],"groupIds":[]}},
"guest":{"enabled":false,"writeable":false},
"createMask":"0664",
"directoryMask":"2775"
}`)
rr := performSambaJSONRequest(t, router, http.MethodPost, "/samba/shares", body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d body=%s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Error != "no_principals_selected_and_guests_not_allowed" {
t.Fatalf("unexpected error: %q", resp.Error)
}
}
func TestUpdateShareReturnsNotFoundWhenShareIsMissing(t *testing.T) {
db := newSambaHandlerTestDB(t, &sambaModels.SambaShare{})
svc := &samba.Service{DB: db}
router := newSambaSharesRouter(svc)
body := []byte(`{
"id":999,
"name":"missing",
"dataset":"dataset-guid",
"permissions":{"read":{"userIds":[],"groupIds":[]},"write":{"userIds":[],"groupIds":[]}},
"guest":{"enabled":true,"writeable":false},
"createMask":"0664",
"directoryMask":"2775"
}`)
rr := performSambaJSONRequest(t, router, http.MethodPut, "/samba/shares", body)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d body=%s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if !strings.HasPrefix(resp.Error, "share_not_found") {
t.Fatalf("expected share_not_found error, got %q", resp.Error)
}
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package sambaHandlers
import (
"net/http/httptest"
"testing"
"github.com/alchemillahq/sylve/internal/testutil"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type handlerAPIResponse[T any] struct {
Status string `json:"status"`
Message string `json:"message"`
Data T `json:"data"`
Error string `json:"error"`
}
func newSambaHandlerTestDB(t *testing.T, migrateModels ...any) *gorm.DB {
t.Helper()
return testutil.NewSQLiteTestDB(t, migrateModels...)
}
func performSambaJSONRequest(
t *testing.T,
router *gin.Engine,
method string,
path string,
body []byte,
) *httptest.ResponseRecorder {
t.Helper()
return testutil.PerformJSONRequest(t, router, method, path, body)
}
+300 -52
View File
@@ -23,6 +23,12 @@ import (
iface "github.com/alchemillahq/sylve/pkg/network/iface"
)
const (
sambaACLType = "nfsv4"
sambaACLMode = "restricted"
sambaACLInherit = "passthrough"
)
func (s *Service) GetGlobalConfig() (sambaModels.SambaSettings, error) {
var settings sambaModels.SambaSettings
if err := s.DB.First(&settings).Error; err != nil {
@@ -100,6 +106,226 @@ func (s *Service) SetGlobalConfig(
return s.WriteConfig(ctx, true)
}
func (s *Service) hasGuestOnlyShares() (bool, error) {
var count int64
if err := s.DB.Model(&sambaModels.SambaShare{}).Where("guest_ok = ?", true).Count(&count).Error; err != nil {
return false, fmt.Errorf("failed_to_check_guest_shares: %w", err)
}
return count > 0, nil
}
func (s *Service) ensureSambaDatasetACLProperties(
ctx context.Context,
dataset *gzfs.Dataset,
strict bool,
) error {
if dataset == nil {
err := fmt.Errorf("dataset_not_found")
if strict {
return err
}
logger.L.Warn().Err(err).Msg("failed_to_enforce_samba_dataset_acl_properties")
return nil
}
if dataset.Type != gzfs.DatasetTypeFilesystem {
err := fmt.Errorf("dataset_not_filesystem: %s", dataset.Name)
if strict {
return err
}
logger.L.Warn().Err(err).Str("dataset", dataset.Name).Msg("failed_to_enforce_samba_dataset_acl_properties")
return nil
}
if err := dataset.SetProperties(
ctx,
"acltype", sambaACLType,
"aclmode", sambaACLMode,
"aclinherit", sambaACLInherit,
); err != nil {
wrapped := fmt.Errorf("failed_to_set_samba_acl_properties_for_dataset_%s: %w", dataset.Name, err)
if strict {
return wrapped
}
logger.L.Warn().Err(wrapped).Str("dataset", dataset.Name).Msg("failed_to_enforce_samba_dataset_acl_properties")
}
return nil
}
func uniquePrincipalNames(names []string) []string {
seen := make(map[string]struct{}, len(names))
out := make([]string, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
func normalizeSambaPrincipalNames(input sambaPrincipalNames) sambaPrincipalNames {
normalized := sambaPrincipalNames{
ReadUsers: uniquePrincipalNames(input.ReadUsers),
WriteUsers: uniquePrincipalNames(input.WriteUsers),
ReadGroups: uniquePrincipalNames(input.ReadGroups),
WriteGroups: uniquePrincipalNames(input.WriteGroups),
}
writeUsers := make(map[string]struct{}, len(normalized.WriteUsers))
for _, user := range normalized.WriteUsers {
writeUsers[user] = struct{}{}
}
filteredReadUsers := make([]string, 0, len(normalized.ReadUsers))
for _, user := range normalized.ReadUsers {
if _, exists := writeUsers[user]; exists {
continue
}
filteredReadUsers = append(filteredReadUsers, user)
}
normalized.ReadUsers = filteredReadUsers
writeGroups := make(map[string]struct{}, len(normalized.WriteGroups))
for _, group := range normalized.WriteGroups {
writeGroups[group] = struct{}{}
}
filteredReadGroups := make([]string, 0, len(normalized.ReadGroups))
for _, group := range normalized.ReadGroups {
if _, exists := writeGroups[group]; exists {
continue
}
filteredReadGroups = append(filteredReadGroups, group)
}
normalized.ReadGroups = filteredReadGroups
return normalized
}
func mergePrincipalNames(lists ...[]string) []string {
merged := make([]string, 0)
for _, list := range lists {
merged = append(merged, list...)
}
return uniquePrincipalNames(merged)
}
func (s *Service) syncSambaDatasetPrincipalACLs(
mountpoint string,
previous sambaPrincipalNames,
desired sambaPrincipalNames,
strict bool,
) error {
if mountpoint == "" || mountpoint == "-" {
err := fmt.Errorf("dataset_not_mounted")
if strict {
return err
}
logger.L.Warn().Err(err).Str("mountpoint", mountpoint).Msg("failed_to_enforce_samba_dataset_principal_acls")
return nil
}
previous = normalizeSambaPrincipalNames(previous)
desired = normalizeSambaPrincipalNames(desired)
removeACL := func(principalType string, principalName string, permissionSet string) {
entry := fmt.Sprintf("%s:%s:%s:fd:allow", principalType, principalName, permissionSet)
if _, err := utils.RunCommand("/bin/setfacl", "-x", entry, mountpoint); err != nil {
logger.L.Warn().
Err(err).
Str("principal", principalName).
Str("principal_type", principalType).
Str("permission_set", permissionSet).
Str("mountpoint", mountpoint).
Msg("failed_to_remove_samba_dataset_principal_acl_entry")
}
}
addACL := func(principalType string, principalName string, permissionSet string) error {
entry := fmt.Sprintf("%s:%s:%s:fd:allow", principalType, principalName, permissionSet)
_, err := utils.RunCommand("/bin/setfacl", "-m", entry, mountpoint)
if err != nil {
wrapped := fmt.Errorf(
"failed_to_set_acl_for_%s_%s_on_%s: %w",
principalType,
principalName,
mountpoint,
err,
)
if strict {
return wrapped
}
logger.L.Warn().
Err(wrapped).
Str("principal", principalName).
Str("principal_type", principalType).
Str("permission_set", permissionSet).
Str("mountpoint", mountpoint).
Msg("failed_to_enforce_samba_dataset_principal_acls")
}
return nil
}
targetUsers := mergePrincipalNames(previous.ReadUsers, previous.WriteUsers, desired.ReadUsers, desired.WriteUsers)
targetGroups := mergePrincipalNames(previous.ReadGroups, previous.WriteGroups, desired.ReadGroups, desired.WriteGroups)
for _, user := range targetUsers {
removeACL("u", user, "read_set")
removeACL("u", user, "modify_set")
}
for _, group := range targetGroups {
removeACL("g", group, "read_set")
removeACL("g", group, "modify_set")
}
for _, user := range desired.ReadUsers {
if err := addACL("u", user, "read_set"); err != nil {
return err
}
}
for _, user := range desired.WriteUsers {
if err := addACL("u", user, "modify_set"); err != nil {
return err
}
}
for _, group := range desired.ReadGroups {
if err := addACL("g", group, "read_set"); err != nil {
return err
}
}
for _, group := range desired.WriteGroups {
if err := addACL("g", group, "modify_set"); err != nil {
return err
}
}
return nil
}
func (s *Service) GlobalConfig() (string, error) {
settings, err := s.GetGlobalConfig()
if err != nil {
@@ -129,6 +355,15 @@ func (s *Service) GlobalConfig() (string, error) {
config += "bind interfaces only = no\n"
}
hasGuestShares, err := s.hasGuestOnlyShares()
if err != nil {
return "", err
}
if hasGuestShares {
config += "map to guest = Bad User\n"
}
if settings.AppleExtensions {
config += "min protocol = SMB2\n"
config += "ea support = yes\n"
@@ -150,7 +385,12 @@ func (s *Service) GlobalConfig() (string, error) {
func (s *Service) ShareConfig(ctx context.Context) (string, error) {
shares := []sambaModels.SambaShare{}
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").Find(&shares).Error; err != nil {
if err := s.DB.
Preload("ReadOnlyUsers").
Preload("WriteableUsers").
Preload("ReadOnlyGroups").
Preload("WriteableGroups").
Find(&shares).Error; err != nil {
return "", fmt.Errorf("failed to retrieve Samba shares: %w", err)
}
@@ -162,10 +402,18 @@ func (s *Service) ShareConfig(ctx context.Context) (string, error) {
return "", fmt.Errorf("failed to fetch dataset for share %s: %v", share.Name, err)
}
if ds == nil {
return "", fmt.Errorf("dataset for share %s not found", share.Name)
}
if ds.Mountpoint == "-" || ds.Mountpoint == "" {
return "", fmt.Errorf("dataset %s for share %s is not mounted", ds.Name, share.Name)
}
// Best-effort during config generation so a single property-set failure
// doesn't prevent Samba from reloading otherwise valid share config.
_ = s.ensureSambaDatasetACLProperties(ctx, ds, false)
datasets[share.Dataset] = ds
}
}
@@ -179,46 +427,65 @@ func (s *Service) ShareConfig(ctx context.Context) (string, error) {
if share.GuestOk {
config.WriteString(fmt.Sprintf("\tguest ok = yes\n"))
config.WriteString("\tguest only = yes\n")
if share.ReadOnly {
config.WriteString("\tread only = yes\n")
} else {
config.WriteString("\tread only = no\n")
}
} else {
config.WriteString(fmt.Sprintf("\tguest ok = no\n"))
}
rGroups := make([]string, 0)
wGroups := make([]string, 0)
principals := namesFromShareAssociations(share)
principals = normalizeSambaPrincipalNames(principals)
if len(share.ReadOnlyGroups) > 0 {
for _, group := range share.ReadOnlyGroups {
rGroups = append(rGroups, group.Name)
if !share.GuestOk {
// Best-effort during config generation to avoid breaking Samba reload.
_ = s.syncSambaDatasetPrincipalACLs(dataset.Mountpoint, sambaPrincipalNames{}, principals, false)
}
readUsers := principals.ReadUsers
writeUsers := principals.WriteUsers
readGroups := principals.ReadGroups
writeGroups := principals.WriteGroups
validUsers := make([]string, 0, len(readUsers)+len(writeUsers)+len(readGroups)+len(writeGroups))
validUsers = append(validUsers, readUsers...)
validUsers = append(validUsers, writeUsers...)
for _, group := range readGroups {
validUsers = append(validUsers, "@"+group)
}
for _, group := range writeGroups {
validUsers = append(validUsers, "@"+group)
}
validUsers = uniquePrincipalNames(validUsers)
writeList := make([]string, 0, len(writeUsers)+len(writeGroups))
writeList = append(writeList, writeUsers...)
for _, group := range writeGroups {
writeList = append(writeList, "@"+group)
}
writeList = uniquePrincipalNames(writeList)
readPrincipalCount := len(readUsers) + len(readGroups)
writePrincipalCount := len(writeUsers) + len(writeGroups)
if !share.GuestOk && len(validUsers) > 0 {
config.WriteString(fmt.Sprintf("\tvalid users = %s\n", strings.Join(validUsers, " ")))
}
if !share.GuestOk {
if writePrincipalCount == 0 || readPrincipalCount > 0 {
config.WriteString("\tread only = yes\n")
} else {
config.WriteString("\tread only = no\n")
}
}
if len(share.WriteableGroups) > 0 {
for _, group := range share.WriteableGroups {
wGroups = append(wGroups, group.Name)
}
}
aGroups := utils.JoinStringSlices(rGroups, wGroups)
writeList := fmt.Sprintf("%s%s", "@", strings.Join(wGroups, " @"))
if len(aGroups) > 0 {
config.WriteString(fmt.Sprintf("\tvalid users = %s\n", "@"+strings.Join(aGroups, " @")))
}
if share.ReadOnly {
config.WriteString("\tread only = yes\n")
}
if share.GuestOk && !share.ReadOnly {
config.WriteString("\tforce user = root\n")
}
if len(rGroups) > 0 && len(wGroups) > 0 {
config.WriteString("\tread only = yes\n")
}
if len(wGroups) > 0 {
config.WriteString(fmt.Sprintf("\twrite list = %s\n", writeList))
if !share.GuestOk && len(writeList) > 0 {
config.WriteString(fmt.Sprintf("\twrite list = %s\n", strings.Join(writeList, " ")))
}
config.WriteString(fmt.Sprintf("\tcreate mask = %s\n", share.CreateMask))
@@ -238,25 +505,6 @@ func (s *Service) ShareConfig(ctx context.Context) (string, error) {
config.WriteString("\tfull_audit:log_secdesc = true\n")
config.WriteString("\n\n")
_, err := utils.RunCommand("/bin/setfacl", "-b", dataset.Mountpoint)
if err != nil {
return "", fmt.Errorf("failed to clear ACLs on mountpoint %s: %w", dataset.Mountpoint, err)
}
if len(rGroups) > 0 {
_, err := utils.RunCommand("/bin/setfacl", "-m", fmt.Sprintf("g:%s:read_set:fd:allow", strings.Join(rGroups, ",")), dataset.Mountpoint)
if err != nil {
return "", fmt.Errorf("failed to set read ACLs for groups %v on mountpoint %s: %w", rGroups, dataset.Mountpoint, err)
}
}
if len(wGroups) > 0 {
_, err := utils.RunCommand("/bin/setfacl", "-m", fmt.Sprintf("g:%s:modify_set:fd:allow", strings.Join(wGroups, ",")), dataset.Mountpoint)
if err != nil {
return "", fmt.Errorf("failed to set write ACLs for groups %v on mountpoint %s: %w", wGroups, dataset.Mountpoint, err)
}
}
}
return config.String(), nil
@@ -0,0 +1,445 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package samba
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/alchemillahq/gzfs"
gzfstest "github.com/alchemillahq/gzfs/testutil"
"github.com/alchemillahq/sylve/internal/db/models"
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
"github.com/alchemillahq/sylve/internal/testutil"
)
type mockDataset struct {
Name string
GUID string
Mountpoint string
}
func newSambaServiceWithMockRunner(t *testing.T) (*Service, *gzfstest.MockRunner) {
t.Helper()
dbConn := testutil.NewSQLiteTestDB(
t,
&models.Group{},
&sambaModels.SambaSettings{},
&sambaModels.SambaShare{},
)
runner := gzfstest.NewMockRunner()
client := gzfs.NewClient(gzfs.Options{
Runner: runner,
})
return &Service{
DB: dbConn,
GZFS: client,
}, runner
}
func addDatasetLookupMocks(t *testing.T, runner *gzfstest.MockRunner, datasets []mockDataset) {
t.Helper()
makeResp := func() string {
resp := map[string]any{
"output_version": map[string]any{
"command": "zfs",
"vers_major": 0,
"vers_minor": 0,
},
"datasets": map[string]any{},
}
dsMap := resp["datasets"].(map[string]any)
for _, ds := range datasets {
pool := ds.Name
if idx := strings.Index(ds.Name, "/"); idx > 0 {
pool = ds.Name[:idx]
}
dsMap[ds.Name] = map[string]any{
"name": ds.Name,
"type": string(gzfs.DatasetTypeFilesystem),
"pool": pool,
"properties": map[string]any{
"guid": map[string]any{
"value": ds.GUID,
"source": map[string]any{
"type": "local",
"data": "-",
},
},
"mountpoint": map[string]any{
"value": ds.Mountpoint,
"source": map[string]any{
"type": "local",
"data": "-",
},
},
},
}
}
b, err := json.Marshal(resp)
if err != nil {
t.Fatalf("failed to marshal mock zfs response: %v", err)
}
return string(b)
}
resp := makeResp()
runner.AddCommand("zfs get -p -H -o name,value guid -j", resp, "", nil)
runner.AddCommand("zfs list -o name,origin,used,available,recordsize,mountpoint,compression,type,volsize,quota,referenced,written,logicalused,usedbydataset,guid,mounted,checksum,aclmode,aclinherit,primarycache,volmode,compressratio,atime,dedup,volblocksize,encryption,encryptionroot,keyformat,keylocation -p", resp, "", nil)
}
func TestGlobalConfigMapToGuestIsConditional(t *testing.T) {
svc, _ := newSambaServiceWithMockRunner(t)
settings := sambaModels.SambaSettings{
UnixCharset: "UTF-8",
Workgroup: "WORKGROUP",
ServerString: "Sylve SMB Server",
Interfaces: "lo0",
BindInterfacesOnly: true,
}
if err := svc.DB.Create(&settings).Error; err != nil {
t.Fatalf("failed creating samba settings: %v", err)
}
cfg, err := svc.GlobalConfig()
if err != nil {
t.Fatalf("GlobalConfig failed: %v", err)
}
if strings.Contains(cfg, "map to guest = Bad User") {
t.Fatalf("did not expect map to guest when there are no guest-only shares")
}
regular := sambaModels.SambaShare{
Name: "regular",
Dataset: "guid-regular",
GuestOk: false,
CreateMask: "0664",
DirectoryMask: "2775",
}
if err := svc.DB.Create(&regular).Error; err != nil {
t.Fatalf("failed creating regular share: %v", err)
}
cfg, err = svc.GlobalConfig()
if err != nil {
t.Fatalf("GlobalConfig failed: %v", err)
}
if strings.Contains(cfg, "map to guest = Bad User") {
t.Fatalf("did not expect map to guest when there are only authenticated shares")
}
guest := sambaModels.SambaShare{
Name: "guest",
Dataset: "guid-guest",
GuestOk: true,
CreateMask: "0664",
DirectoryMask: "2775",
}
if err := svc.DB.Create(&guest).Error; err != nil {
t.Fatalf("failed creating guest share: %v", err)
}
cfg, err = svc.GlobalConfig()
if err != nil {
t.Fatalf("GlobalConfig failed: %v", err)
}
if !strings.Contains(cfg, "map to guest = Bad User") {
t.Fatalf("expected map to guest when at least one guest-only share exists")
}
}
func TestShareConfigGuestOnlyDoesNotEmitUserLists(t *testing.T) {
svc, runner := newSambaServiceWithMockRunner(t)
ctx := context.Background()
share := sambaModels.SambaShare{
Name: "public",
Dataset: "guid-public",
GuestOk: true,
ReadOnly: false,
CreateMask: "0664",
DirectoryMask: "2775",
}
if err := svc.DB.Create(&share).Error; err != nil {
t.Fatalf("failed creating share: %v", err)
}
addDatasetLookupMocks(t, runner, []mockDataset{
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
})
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "", nil)
cfg, err := svc.ShareConfig(ctx)
if err != nil {
t.Fatalf("ShareConfig failed: %v", err)
}
if !strings.Contains(cfg, "[public]") {
t.Fatalf("expected share section for public")
}
if !strings.Contains(cfg, "\tguest ok = yes\n") {
t.Fatalf("expected guest ok = yes")
}
if !strings.Contains(cfg, "\tguest only = yes\n") {
t.Fatalf("expected guest only = yes")
}
if !strings.Contains(cfg, "\tread only = no\n") {
t.Fatalf("expected guest-only writable share to emit read only = no")
}
if strings.Contains(cfg, "valid users =") {
t.Fatalf("did not expect valid users for guest-only share")
}
if strings.Contains(cfg, "write list =") {
t.Fatalf("did not expect write list for guest-only share")
}
if strings.Contains(cfg, "force user =") {
t.Fatalf("did not expect force user for guest-only share")
}
}
func TestShareConfigAuthenticatedEmitsAccessLists(t *testing.T) {
svc, runner := newSambaServiceWithMockRunner(t)
ctx := context.Background()
ro := models.Group{Name: "ro"}
rw := models.Group{Name: "rw"}
if err := svc.DB.Create(&ro).Error; err != nil {
t.Fatalf("failed creating ro group: %v", err)
}
if err := svc.DB.Create(&rw).Error; err != nil {
t.Fatalf("failed creating rw group: %v", err)
}
share := sambaModels.SambaShare{
Name: "secure",
Dataset: "guid-secure",
GuestOk: false,
ReadOnly: false,
ReadOnlyGroups: []models.Group{ro},
WriteableGroups: []models.Group{rw},
CreateMask: "0664",
DirectoryMask: "2775",
}
if err := svc.DB.Create(&share).Error; err != nil {
t.Fatalf("failed creating share: %v", err)
}
addDatasetLookupMocks(t, runner, []mockDataset{
{Name: "tank/secure", GUID: "guid-secure", Mountpoint: "/mnt/secure"},
})
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/secure", "", "", nil)
cfg, err := svc.ShareConfig(ctx)
if err != nil {
t.Fatalf("ShareConfig failed: %v", err)
}
if !strings.Contains(cfg, "\tguest ok = no\n") {
t.Fatalf("expected guest ok = no for authenticated share")
}
if !strings.Contains(cfg, "valid users = @ro @rw") {
t.Fatalf("expected valid users for authenticated share, got:\n%s", cfg)
}
if !strings.Contains(cfg, "write list = @rw") {
t.Fatalf("expected write list for authenticated share, got:\n%s", cfg)
}
if !strings.Contains(cfg, "\tread only = yes\n") {
t.Fatalf("expected read only = yes for split read/write groups")
}
}
func TestShareConfigBestEffortWhenACLPropertySetFails(t *testing.T) {
svc, runner := newSambaServiceWithMockRunner(t)
ctx := context.Background()
share := sambaModels.SambaShare{
Name: "public",
Dataset: "guid-public",
GuestOk: true,
ReadOnly: false,
CreateMask: "0664",
DirectoryMask: "2775",
}
if err := svc.DB.Create(&share).Error; err != nil {
t.Fatalf("failed creating share: %v", err)
}
addDatasetLookupMocks(t, runner, []mockDataset{
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
})
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "failed", errors.New("set failed"))
cfg, err := svc.ShareConfig(ctx)
if err != nil {
t.Fatalf("ShareConfig should not fail when ACL property set fails in best-effort mode: %v", err)
}
if !strings.Contains(cfg, "[public]") {
t.Fatalf("expected share config to still be generated")
}
}
func TestCreateShareRejectsGuestOnlyWithPrincipals(t *testing.T) {
svc, _ := newSambaServiceWithMockRunner(t)
err := svc.CreateShare(
context.Background(),
"public",
"guid-public",
nil,
nil,
nil,
[]uint{1},
true,
false,
"0664",
"2775",
false,
0,
)
if err == nil {
t.Fatal("expected error for guest-only share with principals")
}
if !strings.Contains(err.Error(), "guest_only_share_cannot_have_principals") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateShareRejectsGuestOnlyWithPrincipals(t *testing.T) {
svc, _ := newSambaServiceWithMockRunner(t)
existing := sambaModels.SambaShare{
Name: "private",
Dataset: "guid-private",
GuestOk: false,
CreateMask: "0664",
DirectoryMask: "2775",
}
if err := svc.DB.Create(&existing).Error; err != nil {
t.Fatalf("failed creating existing share: %v", err)
}
err := svc.UpdateShare(
context.Background(),
uint(existing.ID),
existing.Name,
existing.Dataset,
nil,
nil,
nil,
[]uint{1},
true,
false,
"0664",
"2775",
false,
0,
)
if err == nil {
t.Fatal("expected error for guest-only share with principals")
}
if !strings.Contains(err.Error(), "guest_only_share_cannot_have_principals") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateShareFailsWhenACLPropertyEnforcementFails(t *testing.T) {
svc, runner := newSambaServiceWithMockRunner(t)
ctx := context.Background()
addDatasetLookupMocks(t, runner, []mockDataset{
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
})
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "failed", errors.New("set failed"))
err := svc.CreateShare(
ctx,
"public",
"guid-public",
nil,
nil,
nil,
nil,
true,
false,
"0664",
"2775",
false,
0,
)
if err == nil {
t.Fatal("expected ACL enforcement failure")
}
if !strings.Contains(err.Error(), "failed_to_enforce_samba_dataset_acl_properties") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateShareWriteWinsForOverlappingGroupPermissions(t *testing.T) {
if _, err := os.Stat("/bin/setfacl"); err != nil {
t.Skip("setfacl is not available on this test host")
}
svc, runner := newSambaServiceWithMockRunner(t)
ctx := context.Background()
group := models.Group{Name: "staff"}
if err := svc.DB.Create(&group).Error; err != nil {
t.Fatalf("failed creating group: %v", err)
}
addDatasetLookupMocks(t, runner, []mockDataset{
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
})
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "", nil)
runner.AddCommand("/bin/setfacl -m g:staff:modify_set:fd:allow /mnt/public", "", "", nil)
err := svc.CreateShare(
ctx,
"public",
"guid-public",
nil,
nil,
[]uint{group.ID},
[]uint{group.ID},
false,
false,
"0664",
"2775",
false,
0,
)
if err != nil {
t.Fatalf("CreateShare failed: %v", err)
}
var share sambaModels.SambaShare
if err := svc.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").First(&share).Error; err != nil {
t.Fatalf("failed loading created share: %v", err)
}
if len(share.ReadOnlyGroups) != 0 {
t.Fatalf("expected overlapping read group to be removed, got %d read groups", len(share.ReadOnlyGroups))
}
if len(share.WriteableGroups) != 1 || share.WriteableGroups[0].ID != group.ID {
t.Fatalf("expected write group to be retained after normalization")
}
}
+447 -76
View File
@@ -10,16 +10,243 @@ package samba
import (
"context"
"errors"
"fmt"
"github.com/alchemillahq/sylve/internal/db/models"
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
"github.com/alchemillahq/sylve/pkg/utils"
"github.com/alchemillahq/sylve/internal/logger"
"gorm.io/gorm"
)
type sambaPermissionIDs struct {
ReadUserIDs []uint
WriteUserIDs []uint
ReadGroupIDs []uint
WriteGroupIDs []uint
}
type sambaPrincipalNames struct {
ReadUsers []string
WriteUsers []string
ReadGroups []string
WriteGroups []string
}
func uniqueUint(ids []uint) []uint {
seen := make(map[uint]struct{}, len(ids))
out := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func normalizeSambaPermissionIDs(
readUserIDs []uint,
writeUserIDs []uint,
readGroupIDs []uint,
writeGroupIDs []uint,
) sambaPermissionIDs {
normalized := sambaPermissionIDs{
ReadUserIDs: uniqueUint(readUserIDs),
WriteUserIDs: uniqueUint(writeUserIDs),
ReadGroupIDs: uniqueUint(readGroupIDs),
WriteGroupIDs: uniqueUint(writeGroupIDs),
}
writeUsers := make(map[uint]struct{}, len(normalized.WriteUserIDs))
for _, id := range normalized.WriteUserIDs {
writeUsers[id] = struct{}{}
}
filteredReadUsers := make([]uint, 0, len(normalized.ReadUserIDs))
for _, id := range normalized.ReadUserIDs {
if _, exists := writeUsers[id]; exists {
continue
}
filteredReadUsers = append(filteredReadUsers, id)
}
normalized.ReadUserIDs = filteredReadUsers
writeGroups := make(map[uint]struct{}, len(normalized.WriteGroupIDs))
for _, id := range normalized.WriteGroupIDs {
writeGroups[id] = struct{}{}
}
filteredReadGroups := make([]uint, 0, len(normalized.ReadGroupIDs))
for _, id := range normalized.ReadGroupIDs {
if _, exists := writeGroups[id]; exists {
continue
}
filteredReadGroups = append(filteredReadGroups, id)
}
normalized.ReadGroupIDs = filteredReadGroups
return normalized
}
func (ids sambaPermissionIDs) principalCount() int {
return len(ids.ReadUserIDs) + len(ids.WriteUserIDs) + len(ids.ReadGroupIDs) + len(ids.WriteGroupIDs)
}
func collectMissingIDs(expected []uint, present map[uint]struct{}) []uint {
missing := make([]uint, 0)
for _, id := range expected {
if _, exists := present[id]; exists {
continue
}
missing = append(missing, id)
}
return missing
}
func usersByIDs(users []models.User) map[uint]models.User {
byID := make(map[uint]models.User, len(users))
for _, user := range users {
byID[user.ID] = user
}
return byID
}
func groupsByIDs(groups []models.Group) map[uint]models.Group {
byID := make(map[uint]models.Group, len(groups))
for _, group := range groups {
byID[group.ID] = group
}
return byID
}
func usersForIDs(ids []uint, byID map[uint]models.User) []models.User {
out := make([]models.User, 0, len(ids))
for _, id := range ids {
if user, exists := byID[id]; exists {
out = append(out, user)
}
}
return out
}
func groupsForIDs(ids []uint, byID map[uint]models.Group) []models.Group {
out := make([]models.Group, 0, len(ids))
for _, id := range ids {
if group, exists := byID[id]; exists {
out = append(out, group)
}
}
return out
}
func usernames(users []models.User) []string {
names := make([]string, 0, len(users))
for _, user := range users {
names = append(names, user.Username)
}
return names
}
func groupNames(groups []models.Group) []string {
names := make([]string, 0, len(groups))
for _, group := range groups {
names = append(names, group.Name)
}
return names
}
func (s *Service) loadUsersAndGroupsByIDs(
readUserIDs []uint,
writeUserIDs []uint,
readGroupIDs []uint,
writeGroupIDs []uint,
) ([]models.User, []models.User, []models.Group, []models.Group, error) {
allUserIDs := uniqueUint(append(append([]uint{}, readUserIDs...), writeUserIDs...))
allGroupIDs := uniqueUint(append(append([]uint{}, readGroupIDs...), writeGroupIDs...))
var users []models.User
if len(allUserIDs) > 0 {
if err := s.DB.Where("id IN ?", allUserIDs).Find(&users).Error; err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed_to_fetch_users: %w", err)
}
}
foundUsers := make(map[uint]struct{}, len(users))
for _, user := range users {
foundUsers[user.ID] = struct{}{}
}
if missing := collectMissingIDs(allUserIDs, foundUsers); len(missing) > 0 {
return nil, nil, nil, nil, fmt.Errorf("user_not_found: %d", missing[0])
}
var groups []models.Group
if len(allGroupIDs) > 0 {
if err := s.DB.Where("id IN ?", allGroupIDs).Find(&groups).Error; err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed_to_fetch_groups: %w", err)
}
}
foundGroups := make(map[uint]struct{}, len(groups))
for _, group := range groups {
foundGroups[group.ID] = struct{}{}
}
if missing := collectMissingIDs(allGroupIDs, foundGroups); len(missing) > 0 {
return nil, nil, nil, nil, fmt.Errorf("group_not_found: %d", missing[0])
}
usersByID := usersByIDs(users)
groupsByID := groupsByIDs(groups)
readUsers := usersForIDs(readUserIDs, usersByID)
writeUsers := usersForIDs(writeUserIDs, usersByID)
readGroups := groupsForIDs(readGroupIDs, groupsByID)
writeGroups := groupsForIDs(writeGroupIDs, groupsByID)
return readUsers, writeUsers, readGroups, writeGroups, nil
}
func namesFromShareAssociations(share sambaModels.SambaShare) sambaPrincipalNames {
return sambaPrincipalNames{
ReadUsers: usernames(share.ReadOnlyUsers),
WriteUsers: usernames(share.WriteableUsers),
ReadGroups: groupNames(share.ReadOnlyGroups),
WriteGroups: groupNames(share.WriteableGroups),
}
}
func namesFromACLPrincipals(
readUsers []models.User,
writeUsers []models.User,
readGroups []models.Group,
writeGroups []models.Group,
) sambaPrincipalNames {
return sambaPrincipalNames{
ReadUsers: usernames(readUsers),
WriteUsers: usernames(writeUsers),
ReadGroups: groupNames(readGroups),
WriteGroups: groupNames(writeGroups),
}
}
func (s *Service) GetShares() ([]sambaModels.SambaShare, error) {
var shares []sambaModels.SambaShare
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").Find(&shares).Error; err != nil {
if err := s.DB.
Preload("ReadOnlyUsers").
Preload("WriteableUsers").
Preload("ReadOnlyGroups").
Preload("WriteableGroups").
Find(&shares).Error; err != nil {
return nil, fmt.Errorf("failed_to_get_shares: %w", err)
}
return shares, nil
@@ -29,24 +256,37 @@ func (s *Service) CreateShare(
ctx context.Context,
name string,
dataset string,
readOnlyGroups []string,
writeableGroups []string,
readUserIDs []uint,
writeUserIDs []uint,
readGroupIDs []uint,
writeGroupIDs []uint,
guestEnabled bool,
guestWriteable bool,
createMask string,
directoryMask string,
guestOk bool,
readOnly bool,
timeMachine bool,
timeMachineMaxSize uint64) error {
timeMachineMaxSize uint64,
) error {
if err := s.DB.Where("name = ?", name).First(&sambaModels.SambaShare{}).Error; err == nil {
return fmt.Errorf("share_with_name_exists")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("failed_to_check_name_conflict: %w", err)
}
if err := s.DB.Where("dataset = ?", dataset).First(&sambaModels.SambaShare{}).Error; err == nil {
return fmt.Errorf("share_with_dataset_exists")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("failed_to_check_dataset_conflict: %w", err)
}
if len(readOnlyGroups) > 0 && readOnly {
return fmt.Errorf("cannot_create_read_only_share_with_read_only_groups")
normalized := normalizeSambaPermissionIDs(readUserIDs, writeUserIDs, readGroupIDs, writeGroupIDs)
if guestEnabled && normalized.principalCount() > 0 {
return fmt.Errorf("guest_only_share_cannot_have_principals")
}
if !guestEnabled && normalized.principalCount() == 0 {
return fmt.Errorf("no_principals_selected_and_guests_not_allowed")
}
fDataset, err := s.GZFS.ZFS.GetByGUID(ctx, dataset, false)
@@ -58,58 +298,85 @@ func (s *Service) CreateShare(
return fmt.Errorf("dataset_not_found")
}
if fDataset.Mountpoint == "" {
if fDataset.Mountpoint == "" || fDataset.Mountpoint == "-" {
return fmt.Errorf("dataset_not_mounted")
}
allGroups := utils.JoinStringSlices(readOnlyGroups, writeableGroups)
if len(allGroups) == 0 && !guestOk {
return fmt.Errorf("no_groups_selected_and_guests_not_allowed")
if err := s.ensureSambaDatasetACLProperties(ctx, fDataset, true); err != nil {
return fmt.Errorf("failed_to_enforce_samba_dataset_acl_properties: %w", err)
}
for _, group := range allGroups {
if err := s.DB.Where("name = ?", group).First(&models.Group{}).Error; err != nil {
return fmt.Errorf("group_not_found: %s", group)
}
readUsers, writeUsers, readGroups, writeGroups, err := s.loadUsersAndGroupsByIDs(
normalized.ReadUserIDs,
normalized.WriteUserIDs,
normalized.ReadGroupIDs,
normalized.WriteGroupIDs,
)
if err != nil {
return err
}
var roGroups []models.Group
var wrGroups []models.Group
for _, group := range readOnlyGroups {
var g models.Group
if err := s.DB.Where("name = ?", group).First(&g).Error; err != nil {
return fmt.Errorf("read_only_group_not_found: %s", group)
desiredPrincipals := namesFromACLPrincipals(readUsers, writeUsers, readGroups, writeGroups)
if !guestEnabled {
if err := s.syncSambaDatasetPrincipalACLs(
fDataset.Mountpoint,
sambaPrincipalNames{},
desiredPrincipals,
true,
); err != nil {
return fmt.Errorf("failed_to_enforce_samba_dataset_principal_acls: %w", err)
}
roGroups = append(roGroups, g)
}
for _, group := range writeableGroups {
var g models.Group
if err := s.DB.Where("name = ?", group).First(&g).Error; err != nil {
return fmt.Errorf("writeable_group_not_found: %s", group)
}
wrGroups = append(wrGroups, g)
}
share := sambaModels.SambaShare{
Name: name,
Dataset: dataset,
ReadOnlyGroups: roGroups,
WriteableGroups: wrGroups,
CreateMask: createMask,
DirectoryMask: directoryMask,
GuestOk: guestOk,
ReadOnly: readOnly,
GuestOk: guestEnabled,
ReadOnly: !guestWriteable && guestEnabled,
TimeMachine: timeMachine,
TimeMachineMaxSize: timeMachineMaxSize,
}
if err := s.DB.Create(&share).Error; err != nil {
tx := s.DB.Begin()
if err := tx.Create(&share).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_create_share: %w", err)
}
if len(readUsers) > 0 {
if err := tx.Model(&share).Association("ReadOnlyUsers").Append(readUsers); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_read_only_users: %w", err)
}
}
if len(writeUsers) > 0 {
if err := tx.Model(&share).Association("WriteableUsers").Append(writeUsers); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_writeable_users: %w", err)
}
}
if len(readGroups) > 0 {
if err := tx.Model(&share).Association("ReadOnlyGroups").Append(readGroups); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_read_only_groups: %w", err)
}
}
if len(writeGroups) > 0 {
if err := tx.Model(&share).Association("WriteableGroups").Append(writeGroups); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_writeable_groups: %w", err)
}
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed_to_commit_transaction: %w", err)
}
return s.WriteConfig(ctx, true)
}
@@ -118,17 +385,24 @@ func (s *Service) UpdateShare(
id uint,
name string,
dataset string,
readOnlyGroups []string,
writeableGroups []string,
readUserIDs []uint,
writeUserIDs []uint,
readGroupIDs []uint,
writeGroupIDs []uint,
guestEnabled bool,
guestWriteable bool,
createMask string,
directoryMask string,
guestOk bool,
readOnly bool,
timeMachine bool,
timeMachineMaxSize uint64,
) error {
var share sambaModels.SambaShare
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").First(&share, id).Error; err != nil {
if err := s.DB.
Preload("ReadOnlyUsers").
Preload("WriteableUsers").
Preload("ReadOnlyGroups").
Preload("WriteableGroups").
First(&share, id).Error; err != nil {
return fmt.Errorf("share_not_found: %w", err)
}
@@ -156,8 +430,14 @@ func (s *Service) UpdateShare(
}
}
if len(readOnlyGroups) > 0 && readOnly {
return fmt.Errorf("cannot_create_read_only_share_with_read_only_groups")
normalized := normalizeSambaPermissionIDs(readUserIDs, writeUserIDs, readGroupIDs, writeGroupIDs)
if guestEnabled && normalized.principalCount() > 0 {
return fmt.Errorf("guest_only_share_cannot_have_principals")
}
if !guestEnabled && normalized.principalCount() == 0 {
return fmt.Errorf("no_principals_selected_and_guests_not_allowed")
}
fDataset, err := s.GZFS.ZFS.GetByGUID(ctx, dataset, false)
@@ -169,42 +449,78 @@ func (s *Service) UpdateShare(
return fmt.Errorf("dataset_not_found")
}
if fDataset.Mountpoint == "" {
if fDataset.Mountpoint == "" || fDataset.Mountpoint == "-" {
return fmt.Errorf("dataset_not_mounted")
}
allGroups := utils.JoinStringSlices(readOnlyGroups, writeableGroups)
if len(allGroups) == 0 && !guestOk {
return fmt.Errorf("no_groups_selected_and_guests_not_allowed")
if err := s.ensureSambaDatasetACLProperties(ctx, fDataset, true); err != nil {
return fmt.Errorf("failed_to_enforce_samba_dataset_acl_properties: %w", err)
}
for _, group := range allGroups {
if err := s.DB.Where("name = ?", group).First(&models.Group{}).Error; err != nil {
return fmt.Errorf("group_not_found: %s", group)
}
readUsers, writeUsers, readGroups, writeGroups, err := s.loadUsersAndGroupsByIDs(
normalized.ReadUserIDs,
normalized.WriteUserIDs,
normalized.ReadGroupIDs,
normalized.WriteGroupIDs,
)
if err != nil {
return err
}
var roGroups, wrGroups []models.Group
for _, gname := range readOnlyGroups {
var g models.Group
if err := s.DB.Where("name = ?", gname).First(&g).Error; err != nil {
return fmt.Errorf("read_only_group_not_found: %s", gname)
}
roGroups = append(roGroups, g)
previousPrincipals := namesFromShareAssociations(share)
desiredPrincipals := sambaPrincipalNames{}
if !guestEnabled {
desiredPrincipals = namesFromACLPrincipals(readUsers, writeUsers, readGroups, writeGroups)
}
for _, gname := range writeableGroups {
var g models.Group
if err := s.DB.Where("name = ?", gname).First(&g).Error; err != nil {
return fmt.Errorf("writeable_group_not_found: %s", gname)
if dataset == share.Dataset {
if err := s.syncSambaDatasetPrincipalACLs(
fDataset.Mountpoint,
previousPrincipals,
desiredPrincipals,
true,
); err != nil {
return fmt.Errorf("failed_to_enforce_samba_dataset_principal_acls: %w", err)
}
} else {
oldDataset, oldDatasetErr := s.GZFS.ZFS.GetByGUID(ctx, share.Dataset, false)
if oldDatasetErr != nil {
return fmt.Errorf("failed_to_fetch_previous_dataset: %v", oldDatasetErr)
}
if oldDataset != nil && oldDataset.Mountpoint != "" && oldDataset.Mountpoint != "-" {
if err := s.syncSambaDatasetPrincipalACLs(
oldDataset.Mountpoint,
previousPrincipals,
sambaPrincipalNames{},
true,
); err != nil {
return fmt.Errorf("failed_to_cleanup_previous_samba_dataset_principal_acls: %w", err)
}
}
if err := s.syncSambaDatasetPrincipalACLs(
fDataset.Mountpoint,
sambaPrincipalNames{},
desiredPrincipals,
true,
); err != nil {
return fmt.Errorf("failed_to_enforce_samba_dataset_principal_acls: %w", err)
}
wrGroups = append(wrGroups, g)
}
tx := s.DB.Begin()
if err := tx.Model(&share).Association("ReadOnlyUsers").Clear(); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_clear_read_only_users: %w", err)
}
if err := tx.Model(&share).Association("WriteableUsers").Clear(); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_clear_writeable_users: %w", err)
}
if err := tx.Model(&share).Association("ReadOnlyGroups").Clear(); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_clear_read_only_groups: %w", err)
@@ -219,8 +535,8 @@ func (s *Service) UpdateShare(
share.Dataset = dataset
share.CreateMask = createMask
share.DirectoryMask = directoryMask
share.GuestOk = guestOk
share.ReadOnly = readOnly
share.GuestOk = guestEnabled
share.ReadOnly = !guestWriteable && guestEnabled
share.TimeMachine = timeMachine
share.TimeMachineMaxSize = timeMachineMaxSize
@@ -229,14 +545,29 @@ func (s *Service) UpdateShare(
return fmt.Errorf("failed_to_update_share_fields: %w", err)
}
if len(roGroups) > 0 {
if err := tx.Model(&share).Association("ReadOnlyGroups").Append(roGroups); err != nil {
if len(readUsers) > 0 {
if err := tx.Model(&share).Association("ReadOnlyUsers").Append(readUsers); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_read_only_users: %w", err)
}
}
if len(writeUsers) > 0 {
if err := tx.Model(&share).Association("WriteableUsers").Append(writeUsers); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_writeable_users: %w", err)
}
}
if len(readGroups) > 0 {
if err := tx.Model(&share).Association("ReadOnlyGroups").Append(readGroups); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_read_only_groups: %w", err)
}
}
if len(wrGroups) > 0 {
if err := tx.Model(&share).Association("WriteableGroups").Append(wrGroups); err != nil {
if len(writeGroups) > 0 {
if err := tx.Model(&share).Association("WriteableGroups").Append(writeGroups); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_append_writeable_groups: %w", err)
}
@@ -251,13 +582,53 @@ func (s *Service) UpdateShare(
func (s *Service) DeleteShare(ctx context.Context, id uint) error {
var share sambaModels.SambaShare
if err := s.DB.Where("id = ?", id).First(&share).Error; err != nil {
if err := s.DB.
Preload("ReadOnlyUsers").
Preload("WriteableUsers").
Preload("ReadOnlyGroups").
Preload("WriteableGroups").
First(&share, id).Error; err != nil {
return fmt.Errorf("share_not_found: %w", err)
}
if err := s.DB.Delete(&share).Error; err != nil {
previousPrincipals := namesFromShareAssociations(share)
dataset, err := s.GZFS.ZFS.GetByGUID(ctx, share.Dataset, false)
if err != nil {
logger.L.Warn().Err(err).Int("share_id", share.ID).Msg("failed to fetch dataset while cleaning samba ACL principals")
} else if dataset != nil && dataset.Mountpoint != "" && dataset.Mountpoint != "-" {
_ = s.syncSambaDatasetPrincipalACLs(dataset.Mountpoint, previousPrincipals, sambaPrincipalNames{}, false)
}
tx := s.DB.Begin()
if err := tx.Model(&share).Association("ReadOnlyUsers").Clear(); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_clear_read_only_users: %w", err)
}
if err := tx.Model(&share).Association("WriteableUsers").Clear(); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_clear_writeable_users: %w", err)
}
if err := tx.Model(&share).Association("ReadOnlyGroups").Clear(); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_clear_read_only_groups: %w", err)
}
if err := tx.Model(&share).Association("WriteableGroups").Clear(); err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_clear_writeable_groups: %w", err)
}
if err := tx.Delete(&share).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed_to_delete_share: %w", err)
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed_to_commit_transaction: %w", err)
}
return s.WriteConfig(ctx, true)
}
+4 -2
View File
@@ -36,10 +36,12 @@ func TestCreateShareReturnsDatasetConflictBeforeDBDuplicate(t *testing.T) {
"dataset-guid-1",
nil,
nil,
"0664",
"2775",
nil,
nil,
true,
false,
"0664",
"2775",
false,
0,
)
+20 -14
View File
@@ -10,22 +10,26 @@ export async function getSambaShares(): Promise<SambaShare[]> {
export async function createSambaShare(
name: string,
dataset: string,
readOnlyGroups: string[] = [],
writeableGroups: string[] = [],
permissions: {
read: { userIds: number[]; groupIds: number[] };
write: { userIds: number[]; groupIds: number[] };
},
guest: {
enabled: boolean;
writeable: boolean;
},
createMask: string = '',
directoryMask: string = '',
guestOk: boolean = false,
timeMachine: boolean = false,
timeMachineMaxSize: number = 0
): Promise<APIResponse> {
return await apiRequest('/samba/shares', APIResponseSchema, 'POST', {
name,
dataset,
readOnlyGroups,
writeableGroups,
permissions,
guest,
createMask,
directoryMask,
guestOk,
timeMachine,
timeMachineMaxSize
});
@@ -35,12 +39,16 @@ export async function updateSambaShare(
id: number,
name: string,
dataset: string,
readOnlyGroups: string[] = [],
writeableGroups: string[] = [],
permissions: {
read: { userIds: number[]; groupIds: number[] };
write: { userIds: number[]; groupIds: number[] };
},
guest: {
enabled: boolean;
writeable: boolean;
},
createMask: string = '',
directoryMask: string = '',
guestOk: boolean = false,
readOnly: boolean = false,
timeMachine: boolean = false,
timeMachineMaxSize: number = 0
): Promise<APIResponse> {
@@ -48,12 +56,10 @@ export async function updateSambaShare(
id,
name,
dataset,
readOnlyGroups,
writeableGroups,
permissions,
guest,
createMask,
directoryMask,
guestOk,
readOnly,
timeMachine,
timeMachineMaxSize
});
+180 -106
View File
@@ -6,7 +6,7 @@
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import type { Group } from '$lib/types/auth';
import type { Group, User } from '$lib/types/auth';
import type { APIResponse } from '$lib/types/common';
import type { SambaShare } from '$lib/types/samba/shares';
import type { Dataset } from '$lib/types/zfs/dataset';
@@ -17,6 +17,7 @@
shares: SambaShare[];
datasets: Dataset[];
groups: Group[];
users: User[];
share?: SambaShare | null;
edit?: boolean;
reload?: boolean;
@@ -28,12 +29,23 @@
shares,
datasets,
groups,
users,
share,
edit = false,
reload = $bindable(),
appleExtensions = false
}: Props = $props();
const userOptions = users.map((user) => ({
label: user.username,
value: String(user.id)
}));
const groupOptions = groups.map((group) => ({
label: group.name,
value: String(group.id)
}));
// svelte-ignore state_referenced_locally
let options = {
name: share ? share.name : '',
@@ -55,36 +67,68 @@
}))
}
},
readOnlyGroups: {
readUsers: {
combobox: {
open: false,
value: share ? share.readOnlyGroups.map((group) => group.name) : ([] as string[]),
options: groups.map((group) => ({
label: group.name,
value: group.name
}))
value: share ? share.permissions.read.users.map((user) => String(user.id)) : ([] as string[]),
options: userOptions
}
},
writeableGroups: {
writeUsers: {
combobox: {
open: false,
value: share ? share.writeableGroups.map((group) => group.name) : ([] as string[]),
options: groups.map((group) => ({
label: group.name,
value: group.name
}))
value: share
? share.permissions.write.users.map((user) => String(user.id))
: ([] as string[]),
options: userOptions
}
},
readGroups: {
combobox: {
open: false,
value: share ? share.permissions.read.groups.map((group) => String(group.id)) : ([] as string[]),
options: groupOptions
}
},
writeGroups: {
combobox: {
open: false,
value: share
? share.permissions.write.groups.map((group) => String(group.id))
: ([] as string[]),
options: groupOptions
}
},
createMask: share ? share.createMask : '0664',
directoryMask: share ? share.directoryMask : '2775',
guestOk: share ? share.guestOk : false,
readOnly: share ? share.readOnly : false,
guest: {
enabled: share ? share.guest.enabled : false,
writeable: share ? share.guest.writeable : false
},
timeMachine: share ? share.timeMachine : false,
timeMachineMaxSize: share ? share.timeMachineMaxSize : 0
};
let properties = $state(options);
function normalizeWriteWins() {
const writeUsers = new Set(properties.writeUsers.combobox.value);
properties.readUsers.combobox.value = properties.readUsers.combobox.value.filter(
(id) => !writeUsers.has(id)
);
const writeGroups = new Set(properties.writeGroups.combobox.value);
properties.readGroups.combobox.value = properties.readGroups.combobox.value.filter(
(id) => !writeGroups.has(id)
);
}
function toIDList(values: string[]): number[] {
return values
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0);
}
async function createOrEdit() {
let error = '';
@@ -96,20 +140,20 @@
error = 'Name is required';
} else if (properties.dataset.combobox.value === '') {
error = 'Dataset is required';
} else if (
properties.readOnlyGroups.combobox.value.length === 0 &&
properties.writeableGroups.combobox.value.length === 0 &&
!properties.guestOk
) {
error = 'No groups selected and guests are not allowed';
}
if (
properties.readOnlyGroups.combobox.value.some((group) =>
properties.writeableGroups.combobox.value.includes(group)
)
) {
error = 'Share cannot have overlapping groups';
const totalPrincipals =
properties.readUsers.combobox.value.length +
properties.writeUsers.combobox.value.length +
properties.readGroups.combobox.value.length +
properties.writeGroups.combobox.value.length;
if (!properties.guest.enabled && totalPrincipals === 0) {
error = 'Select at least one user or group for authenticated access';
}
if (properties.guest.enabled && totalPrincipals > 0) {
error = 'Guest-only shares cannot include users or groups';
}
if (error) {
@@ -119,6 +163,22 @@
return;
}
const permissions = {
read: {
userIds: toIDList(properties.readUsers.combobox.value),
groupIds: toIDList(properties.readGroups.combobox.value)
},
write: {
userIds: toIDList(properties.writeUsers.combobox.value),
groupIds: toIDList(properties.writeGroups.combobox.value)
}
};
const guest = {
enabled: properties.guest.enabled,
writeable: properties.guest.writeable
};
let response: APIResponse;
if (edit) {
@@ -126,12 +186,10 @@
share!.id,
properties.name,
properties.dataset.combobox.value,
properties.readOnlyGroups.combobox.value,
properties.writeableGroups.combobox.value,
permissions,
guest,
properties.createMask,
properties.directoryMask,
properties.guestOk,
properties.readOnly,
properties.timeMachine,
Number(properties.timeMachineMaxSize)
);
@@ -139,11 +197,10 @@
response = await createSambaShare(
properties.name,
properties.dataset.combobox.value,
properties.readOnlyGroups.combobox.value,
properties.writeableGroups.combobox.value,
permissions,
guest,
properties.createMask,
properties.directoryMask,
properties.guestOk,
properties.timeMachine,
Number(properties.timeMachineMaxSize)
);
@@ -167,11 +224,22 @@
}
$effect(() => {
if (properties.readOnly) {
if (properties.readOnlyGroups.combobox.value.length > 0) {
properties.readOnlyGroups.combobox.value = [];
if (properties.guest.enabled) {
if (properties.readUsers.combobox.value.length > 0) {
properties.readUsers.combobox.value = [];
}
if (properties.writeUsers.combobox.value.length > 0) {
properties.writeUsers.combobox.value = [];
}
if (properties.readGroups.combobox.value.length > 0) {
properties.readGroups.combobox.value = [];
}
if (properties.writeGroups.combobox.value.length > 0) {
properties.writeGroups.combobox.value = [];
}
}
normalizeWriteWins();
});
</script>
@@ -239,35 +307,69 @@
bind:open={properties.dataset.combobox.open}
bind:value={properties.dataset.combobox.value}
data={properties.dataset.combobox.options}
multiple={false}
width="w-full"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomComboBox
label="Read-Only Groups"
placeholder="Select groups"
bind:open={properties.readOnlyGroups.combobox.open}
bind:value={properties.readOnlyGroups.combobox.value}
data={properties.readOnlyGroups.combobox.options}
disabled={properties.readOnly}
multiple={true}
width="w-full"
width="w-2/5"
/>
<CustomComboBox
label="Writeable Groups"
placeholder="Select groups"
bind:open={properties.writeableGroups.combobox.open}
bind:value={properties.writeableGroups.combobox.value}
data={properties.writeableGroups.combobox.options}
multiple={true}
width="w-full"
/>
</div>
<div class="flex items-center justify-between gap-2 rounded border p-2 md:col-span-2">
<Label for="guest-mode">Guest Only</Label>
<Checkbox id="guest-mode" bind:checked={properties.guest.enabled} />
</div>
{#if !properties.guest.enabled}
<CustomComboBox
label="Read Users"
placeholder="Select users"
bind:open={properties.readUsers.combobox.open}
bind:value={properties.readUsers.combobox.value}
data={properties.readUsers.combobox.options}
multiple={true}
showCount={true}
showCountLabel=" users"
width="w-2/5"
/>
<CustomComboBox
label="Write Users"
placeholder="Select users"
bind:open={properties.writeUsers.combobox.open}
bind:value={properties.writeUsers.combobox.value}
data={properties.writeUsers.combobox.options}
multiple={true}
showCount={true}
showCountLabel=" users"
width="w-2/5"
/>
<CustomComboBox
label="Read Groups"
placeholder="Select groups"
bind:open={properties.readGroups.combobox.open}
bind:value={properties.readGroups.combobox.value}
data={properties.readGroups.combobox.options}
multiple={true}
showCount={true}
showCountLabel=" groups"
width="w-2/5"
/>
<CustomComboBox
label="Write Groups"
placeholder="Select groups"
bind:open={properties.writeGroups.combobox.open}
bind:value={properties.writeGroups.combobox.value}
data={properties.writeGroups.combobox.options}
multiple={true}
showCount={true}
showCountLabel=" groups"
width="w-2/5"
/>
{:else}
<div class="flex items-center justify-between gap-2 rounded border p-2 md:col-span-2">
<Label for="guest-writeable">Guest Writeable</Label>
<Checkbox id="guest-writeable" bind:checked={properties.guest.writeable} />
</div>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomValueInput
label="Create Mask"
placeholder="0664"
@@ -281,55 +383,27 @@
bind:value={properties.directoryMask}
classes="flex-1 space-y-1.5"
/>
</div>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<Checkbox id="guests" bind:checked={properties.guestOk} />
<Label for="guests" class="text-sm font-medium">Guests</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox id="read-only" bind:checked={properties.readOnly} />
<Label for="read-only" class="text-sm font-medium">Read Only</Label>
</div>
{#if appleExtensions}
<div class="flex items-center space-x-2">
<div class="flex items-center justify-between gap-2 rounded border p-2 md:col-span-2">
<Label for="time-machine">Time Machine</Label>
<Checkbox id="time-machine" bind:checked={properties.timeMachine} />
<Label for="time-machine" class="text-sm font-medium">Time Machine</Label>
</div>
{#if properties.timeMachine}
<CustomValueInput
label="Time Machine Max Size (GB)"
placeholder="0"
bind:value={properties.timeMachineMaxSize}
classes="flex-1 space-y-1.5 md:col-span-2"
/>
{/if}
{/if}
</div>
{#if appleExtensions && properties.timeMachine}
<CustomValueInput
label="Time Machine Max Size"
hint="Time Machine Max Size (GB, 0 = unlimited)"
placeholder="0"
bind:value={properties.timeMachineMaxSize}
classes="flex-1 space-y-1.5"
type="number"
/>
{/if}
<Dialog.Footer class="mt-4">
<div class="flex items-center justify-end space-x-4">
<Button
size="sm"
type="button"
class="h-8 w-full lg:w-28"
onclick={() => {
createOrEdit();
}}
>
{#if edit}
Edit
{:else}
Create
{/if}
</Button>
</div>
</Dialog.Footer>
<div class="mt-4 flex justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button onclick={createOrEdit}>{edit ? 'Save' : 'Create'}</Button>
</div>
</Dialog.Content>
</Dialog.Root>
+28 -5
View File
@@ -1,16 +1,39 @@
import { z } from 'zod/v4';
import { GroupSchema } from '../auth';
import { GroupSchema, UserSchema } from '../auth';
export const SambaPrincipalUserSchema = UserSchema.pick({
id: true,
username: true
});
export const SambaPrincipalGroupSchema = GroupSchema.pick({
id: true,
name: true
});
export const SambaPrincipalSetSchema = z.object({
users: z.array(SambaPrincipalUserSchema).default([]),
groups: z.array(SambaPrincipalGroupSchema).default([])
});
export const SambaPermissionsSchema = z.object({
read: SambaPrincipalSetSchema,
write: SambaPrincipalSetSchema
});
export const SambaGuestSchema = z.object({
enabled: z.boolean(),
writeable: z.boolean()
});
export const SambaShareSchema = z.object({
id: z.number(),
name: z.string(),
dataset: z.string(),
readOnlyGroups: z.preprocess((val) => (val == null ? [] : val), z.array(GroupSchema)),
writeableGroups: z.preprocess((val) => (val == null ? [] : val), z.array(GroupSchema)),
permissions: SambaPermissionsSchema,
guest: SambaGuestSchema,
createMask: z.string(),
directoryMask: z.string(),
guestOk: z.boolean(),
readOnly: z.boolean(),
timeMachine: z.boolean().default(false),
timeMachineMaxSize: z.number().default(0),
createdAt: z.string(),
@@ -1,5 +1,6 @@
<script lang="ts">
import { listGroups } from '$lib/api/auth/groups';
import { listUsers } from '$lib/api/auth/local';
import { getSambaConfig } from '$lib/api/samba/config';
import { deleteSambaShare, getSambaShares } from '$lib/api/samba/share';
import { getDatasets } from '$lib/api/zfs/datasets';
@@ -8,7 +9,7 @@
import TreeTable from '$lib/components/custom/TreeTable.svelte';
import Search from '$lib/components/custom/TreeTable/Search.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import type { Group } from '$lib/types/auth';
import type { Group, User } from '$lib/types/auth';
import type { Column, Row } from '$lib/types/components/tree-table';
import type { SambaConfig } from '$lib/types/samba/config';
import type { SambaShare } from '$lib/types/samba/shares';
@@ -23,6 +24,7 @@
shares: SambaShare[];
datasets: Dataset[];
groups: Group[];
users: User[];
sambaConfig: SambaConfig;
}
@@ -67,6 +69,19 @@
}
);
// svelte-ignore state_referenced_locally
let users = resource(
() => 'users',
async () => {
const result = await listUsers();
updateCache('users', result);
return result;
},
{
initialValue: data.users
}
);
// svelte-ignore state_referenced_locally
let sambaConfig = resource(
() => 'samba-config',
@@ -89,6 +104,7 @@
datasets.refetch();
shares.refetch();
groups.refetch();
users.refetch();
reload = false;
}
}
@@ -113,22 +129,16 @@
let properties = $state(options);
let query = $state('');
function generateTableData(
shares: SambaShare[],
datasets: Dataset[]
): {
function generateTableData(shares: SambaShare[], datasets: Dataset[]): {
rows: Row[];
columns: Column[];
} {
function groupFormatter(cell: CellComponent) {
const groups = cell.getValue() as Group[];
if (!groups?.length) return '-';
function principalFormatter(cell: CellComponent) {
const principals = cell.getValue() as string[];
if (!principals?.length) return '-';
const shown = groups
.slice(0, 5)
.map((g) => g.name)
.join(', ');
return groups.length > 5 ? `${shown}, …` : shown;
const shown = principals.slice(0, 5).join(', ');
return principals.length > 5 ? `${shown}, …` : shown;
}
const rows: Row[] = [];
@@ -147,14 +157,14 @@
title: 'Mount Point'
},
{
field: 'readOnlyGroups',
title: 'Read-Only Groups',
formatter: groupFormatter
field: 'readAccess',
title: 'Read Access',
formatter: principalFormatter
},
{
field: 'writeableGroups',
title: 'Writeable Groups',
formatter: groupFormatter
field: 'writeAccess',
title: 'Write Access',
formatter: principalFormatter
},
{
field: 'created',
@@ -168,12 +178,21 @@
for (const share of shares) {
const dataset = datasets.find((ds) => ds.guid === share.dataset);
const readAccess = [
...share.permissions.read.users.map((user) => user.username),
...share.permissions.read.groups.map((group) => `@${group.name}`)
];
const writeAccess = [
...share.permissions.write.users.map((user) => user.username),
...share.permissions.write.groups.map((group) => `@${group.name}`)
];
const row: Row = {
id: share.id,
name: share.name,
mountpoint: dataset ? dataset.mountpoint : '-',
readOnlyGroups: share.readOnlyGroups || [],
writeableGroups: share.writeableGroups || [],
readAccess,
writeAccess,
created: share.createdAt
};
@@ -266,6 +285,7 @@
shares={shares.current}
datasets={datasets.current}
groups={groups.current}
users={users.current}
appleExtensions={sambaConfig.current.appleExtensions}
bind:reload
/>
@@ -277,6 +297,7 @@
shares={shares.current}
datasets={datasets.current}
groups={groups.current}
users={users.current}
share={properties.edit.share}
edit={properties.edit.open}
appleExtensions={sambaConfig.current.appleExtensions}
@@ -1,4 +1,5 @@
import { listGroups } from '$lib/api/auth/groups';
import { listUsers } from '$lib/api/auth/local';
import { getSambaConfig } from '$lib/api/samba/config';
import { getSambaShares } from '$lib/api/samba/share';
import { getDatasets } from '$lib/api/zfs/datasets';
@@ -8,7 +9,7 @@ import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [datasets, shares, groups, sambaConfig] = await Promise.all([
const [datasets, shares, groups, users, sambaConfig] = await Promise.all([
cachedFetch(
'zfs-filesystems',
async () => await getDatasets(GZFSDatasetTypeSchema.enum.FILESYSTEM),
@@ -16,6 +17,7 @@ export async function load() {
),
cachedFetch('samba-shares', async () => await getSambaShares(), cacheDuration),
cachedFetch('groups', async () => await listGroups(), cacheDuration),
cachedFetch('users', async () => await listUsers(), cacheDuration),
cachedFetch('samba-config', async () => await getSambaConfig(), cacheDuration)
]);
@@ -23,6 +25,7 @@ export async function load() {
datasets,
shares,
groups,
users,
sambaConfig
};
}