mirror of
https://github.com/h44z/wg-portal.git
synced 2026-03-24 00:56:26 +00:00
feat: Implement LDAP interface-specific provisioning filters (#642)
* Implement LDAP filter-based access control for interface provisioning * test: add unit tests for LDAP interface filtering logic * smaller improvements / cleanup --------- Co-authored-by: jc <37738506+theguy147@users.noreply.github.com> Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
This commit is contained in:
@@ -80,7 +80,7 @@ func main() {
|
|||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
auditRecorder.StartBackgroundJobs(ctx)
|
auditRecorder.StartBackgroundJobs(ctx)
|
||||||
|
|
||||||
userManager, err := users.NewUserManager(cfg, eventBus, database, database)
|
userManager, err := users.NewUserManager(cfg, eventBus, database, database, database)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
userManager.StartBackgroundJobs(ctx)
|
userManager.StartBackgroundJobs(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ auth:
|
|||||||
memberof: memberOf
|
memberof: memberOf
|
||||||
admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL
|
admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL
|
||||||
registration_enabled: true
|
registration_enabled: true
|
||||||
|
# Restrict interface access based on LDAP filters
|
||||||
|
interface_filter:
|
||||||
|
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
|
||||||
log_user_info: true
|
log_user_info: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -742,6 +742,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
|||||||
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
|
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
|
||||||
If the filter returns multiple or no users, the login will fail.
|
If the filter returns multiple or no users, the login will fail.
|
||||||
|
|
||||||
|
#### `interface_filter`
|
||||||
|
- **Default:** *(empty)*
|
||||||
|
- **Description:** A map of LDAP filters to restrict access to specific WireGuard interfaces. The map keys are the interface identifiers (e.g., `wg0`), and the values are LDAP filters. Only users matching the filter will be allowed to provision peers for the respective interface.
|
||||||
|
For example:
|
||||||
|
```yaml
|
||||||
|
interface_filter:
|
||||||
|
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
|
||||||
|
wg1: "(description=special-access)"
|
||||||
|
```
|
||||||
|
|
||||||
#### `admin_group`
|
#### `admin_group`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
- **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal.
|
- **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal.
|
||||||
|
|||||||
@@ -147,6 +147,26 @@ You can map users to admin roles based on their group membership in the LDAP ser
|
|||||||
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
|
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
|
||||||
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
|
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
|
||||||
|
|
||||||
|
### Interface-specific Provisioning Filters
|
||||||
|
|
||||||
|
You can restrict which users are allowed to provision peers for specific WireGuard interfaces by setting the `interface_filter` property.
|
||||||
|
This property is a map where each key corresponds to a WireGuard interface identifier, and the value is an LDAP filter.
|
||||||
|
A user will only be able to see and provision peers for an interface if they match the specified LDAP filter for that interface.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
ldap:
|
||||||
|
- provider_name: "ldap1"
|
||||||
|
# ... other settings
|
||||||
|
interface_filter:
|
||||||
|
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
|
||||||
|
wg1: "(department=IT)"
|
||||||
|
```
|
||||||
|
|
||||||
|
This feature works by materializing the list of authorized users for each interface during the periodic LDAP synchronization.
|
||||||
|
Even if a user bypasses the UI, the backend will enforce these restrictions at the service layer.
|
||||||
|
|
||||||
|
|
||||||
## User Synchronization
|
## User Synchronization
|
||||||
|
|
||||||
|
|||||||
@@ -44,3 +44,11 @@ All peers associated with that user will also be disabled.
|
|||||||
|
|
||||||
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
|
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
|
||||||
This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.
|
This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.
|
||||||
|
|
||||||
|
##### Interface-specific Access Materialization
|
||||||
|
|
||||||
|
If `interface_filter` is configured in the LDAP provider, the synchronization process will evaluate these filters for each enabled user.
|
||||||
|
The results are materialized in the `interfaces` table of the database in a hidden field.
|
||||||
|
This materialized list is used by the backend to quickly determine if a user has permission to provision peers for a specific interface, without having to query the LDAP server for every request.
|
||||||
|
The list is refreshed every time the LDAP synchronization runs.
|
||||||
|
For more details on how to configure these filters, see the [Authentication](./authentication.md#interface-specific-provisioning-filters) section.
|
||||||
@@ -74,6 +74,7 @@ export const profileStore = defineStore('profile', {
|
|||||||
},
|
},
|
||||||
hasStatistics: (state) => state.statsEnabled,
|
hasStatistics: (state) => state.statsEnabled,
|
||||||
CountInterfaces: (state) => state.interfaces.length,
|
CountInterfaces: (state) => state.interfaces.length,
|
||||||
|
HasInterface: (state) => (id) => state.interfaces.some((i) => i.Identifier === id),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
afterPageSizeChange() {
|
afterPageSizeChange() {
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ onMounted(async () => {
|
|||||||
<div class="col-12 col-lg-5">
|
<div class="col-12 col-lg-5">
|
||||||
<h2 class="mt-2">{{ $t('profile.headline') }}</h2>
|
<h2 class="mt-2">{{ $t('profile.headline') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-lg-3 text-lg-end" v-if="!settings.Setting('SelfProvisioning') || profile.CountInterfaces===0">
|
||||||
|
</div>
|
||||||
<div class="col-12 col-lg-4 text-lg-end">
|
<div class="col-12 col-lg-4 text-lg-end">
|
||||||
<div class="form-group d-inline">
|
<div class="form-group d-inline">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
@@ -90,8 +92,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-3 text-lg-end">
|
<div class="col-12 col-lg-3 text-lg-end" v-if="settings.Setting('SelfProvisioning') && profile.CountInterfaces>0">
|
||||||
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
|
<div class="form-group">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
|
<button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
|
||||||
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
|
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
|
||||||
@@ -160,8 +162,7 @@ onMounted(async () => {
|
|||||||
</td>
|
</td>
|
||||||
<td v-if="profile.hasStatistics">
|
<td v-if="profile.hasStatistics">
|
||||||
<div v-if="profile.Statistics(peer.Identifier).IsConnected">
|
<div v-if="profile.Statistics(peer.Identifier).IsConnected">
|
||||||
<span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span>
|
<span class="badge rounded-pill bg-success" :title="$t('profile.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + profile.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small>
|
||||||
<span :title="profile.Statistics(peer.Identifier).LastHandshake">{{ $t('profile.peer-connected') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
|
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
|
||||||
@@ -174,7 +175,7 @@ onMounted(async () => {
|
|||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i
|
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i
|
||||||
class="fas fa-eye me-2"></i></a>
|
class="fas fa-eye me-2"></i></a>
|
||||||
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier"><i
|
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier" v-if="settings.Setting('SelfProvisioning') && profile.HasInterface(peer.InterfaceIdentifier)"><i
|
||||||
class="fas fa-cog"></i></a>
|
class="fas fa-cog"></i></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.Ldap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update interface allowed users based on LDAP filters
|
||||||
|
err = m.updateInterfaceLdapFilters(ctx, conn, provider)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,3 +243,59 @@ func (m Manager) disableMissingLdapUsers(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Manager) updateInterfaceLdapFilters(
|
||||||
|
ctx context.Context,
|
||||||
|
conn *ldap.Conn,
|
||||||
|
provider *config.LdapProvider,
|
||||||
|
) error {
|
||||||
|
if len(provider.InterfaceFilter) == 0 {
|
||||||
|
return nil // nothing to do if no interfaces are configured for this provider
|
||||||
|
}
|
||||||
|
|
||||||
|
for ifaceName, groupFilter := range provider.InterfaceFilter {
|
||||||
|
ifaceId := domain.InterfaceIdentifier(ifaceName)
|
||||||
|
|
||||||
|
// Combined filter: user must match the provider's base SyncFilter AND the interface's LdapGroupFilter
|
||||||
|
combinedFilter := fmt.Sprintf("(&(%s)(%s))", provider.SyncFilter, groupFilter)
|
||||||
|
|
||||||
|
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, combinedFilter, &provider.FieldMap)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to find users for interface filter",
|
||||||
|
"interface", ifaceId,
|
||||||
|
"provider", provider.ProviderName,
|
||||||
|
"error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
|
||||||
|
for _, rawUser := range rawUsers {
|
||||||
|
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, provider.FieldMap.UserIdentifier, ""))
|
||||||
|
if userId != "" {
|
||||||
|
matchedUserIds = append(matchedUserIds, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the interface
|
||||||
|
err = m.interfaces.SaveInterface(ctx, ifaceId, func(i *domain.Interface) (*domain.Interface, error) {
|
||||||
|
if i.LdapAllowedUsers == nil {
|
||||||
|
i.LdapAllowedUsers = make(map[string][]domain.UserIdentifier)
|
||||||
|
}
|
||||||
|
i.LdapAllowedUsers[provider.ProviderName] = matchedUserIds
|
||||||
|
return i, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to save interface ldap allowed users",
|
||||||
|
"interface", ifaceId,
|
||||||
|
"provider", provider.ProviderName,
|
||||||
|
"error", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("updated interface ldap allowed users",
|
||||||
|
"interface", ifaceId,
|
||||||
|
"provider", provider.ProviderName,
|
||||||
|
"matched_count", len(matchedUserIds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ type PeerDatabaseRepo interface {
|
|||||||
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InterfaceDatabaseRepo interface {
|
||||||
|
// SaveInterface saves the interface with the given identifier.
|
||||||
|
SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(i *domain.Interface) (*domain.Interface, error)) error
|
||||||
|
}
|
||||||
|
|
||||||
type EventBus interface {
|
type EventBus interface {
|
||||||
// Publish sends a message to the message bus.
|
// Publish sends a message to the message bus.
|
||||||
Publish(topic string, args ...any)
|
Publish(topic string, args ...any)
|
||||||
@@ -53,19 +58,24 @@ type Manager struct {
|
|||||||
bus EventBus
|
bus EventBus
|
||||||
users UserDatabaseRepo
|
users UserDatabaseRepo
|
||||||
peers PeerDatabaseRepo
|
peers PeerDatabaseRepo
|
||||||
|
interfaces InterfaceDatabaseRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserManager creates a new user manager instance.
|
// NewUserManager creates a new user manager instance.
|
||||||
func NewUserManager(cfg *config.Config, bus EventBus, users UserDatabaseRepo, peers PeerDatabaseRepo) (
|
func NewUserManager(
|
||||||
*Manager,
|
cfg *config.Config,
|
||||||
error,
|
bus EventBus,
|
||||||
) {
|
users UserDatabaseRepo,
|
||||||
|
peers PeerDatabaseRepo,
|
||||||
|
interfaces InterfaceDatabaseRepo,
|
||||||
|
) (*Manager, error) {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
|
|
||||||
users: users,
|
users: users,
|
||||||
peers: peers,
|
peers: peers,
|
||||||
|
interfaces: interfaces,
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type InterfaceAndPeerDatabaseRepo interface {
|
|||||||
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
||||||
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
|
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
|
||||||
GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error)
|
GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error)
|
||||||
|
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WgQuickController interface {
|
type WgQuickController interface {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import (
|
|||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetInterface returns the interface for the given interface identifier.
|
||||||
|
func (m Manager) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
|
||||||
|
return m.db.GetInterface(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
|
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
|
||||||
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
|
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
|
||||||
*domain.Interface,
|
*domain.Interface,
|
||||||
@@ -63,12 +68,17 @@ func (m Manager) GetAllInterfacesAndPeers(ctx context.Context) ([]domain.Interfa
|
|||||||
|
|
||||||
// GetUserInterfaces returns all interfaces that are available for users to create new peers.
|
// GetUserInterfaces returns all interfaces that are available for users to create new peers.
|
||||||
// If self-provisioning is disabled, this function will return an empty list.
|
// If self-provisioning is disabled, this function will return an empty list.
|
||||||
// At the moment, there are no interfaces specific to single users, thus the user id is not used.
|
func (m Manager) GetUserInterfaces(ctx context.Context, userId domain.UserIdentifier) ([]domain.Interface, error) {
|
||||||
func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier) ([]domain.Interface, error) {
|
|
||||||
if !m.cfg.Core.SelfProvisioningAllowed {
|
if !m.cfg.Core.SelfProvisioningAllowed {
|
||||||
return nil, nil // self-provisioning is disabled - no interfaces for users
|
return nil, nil // self-provisioning is disabled - no interfaces for users
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := m.db.GetUser(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load user for interface group verification", "user", userId, "error", err)
|
||||||
|
return nil, nil // fail closed
|
||||||
|
}
|
||||||
|
|
||||||
interfaces, err := m.db.GetAllInterfaces(ctx)
|
interfaces, err := m.db.GetAllInterfaces(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to load all interfaces: %w", err)
|
return nil, fmt.Errorf("unable to load all interfaces: %w", err)
|
||||||
@@ -83,6 +93,9 @@ func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier)
|
|||||||
if iface.Type != domain.InterfaceTypeServer {
|
if iface.Type != domain.InterfaceTypeServer {
|
||||||
continue // skip client interfaces
|
continue // skip client interfaces
|
||||||
}
|
}
|
||||||
|
if !user.IsAdmin && !iface.IsUserAllowed(userId, m.cfg) {
|
||||||
|
continue // user not allowed due to LDAP group filter
|
||||||
|
}
|
||||||
|
|
||||||
userInterfaces = append(userInterfaces, iface.PublicInfo())
|
userInterfaces = append(userInterfaces, iface.PublicInfo())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,3 +93,126 @@ func TestImportPeer_AddressMapping(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *mockDB) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||||
|
return &domain.User{
|
||||||
|
Identifier: id,
|
||||||
|
IsAdmin: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterface_IsUserAllowed(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Auth: config.Auth{
|
||||||
|
Ldap: []config.LdapProvider{
|
||||||
|
{
|
||||||
|
ProviderName: "ldap1",
|
||||||
|
InterfaceFilter: map[string]string{
|
||||||
|
"wg0": "(memberOf=CN=VPNUsers,...)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
iface domain.Interface
|
||||||
|
userId domain.UserIdentifier
|
||||||
|
expect bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Unrestricted interface",
|
||||||
|
iface: domain.Interface{
|
||||||
|
Identifier: "wg1",
|
||||||
|
},
|
||||||
|
userId: "user1",
|
||||||
|
expect: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Restricted interface - user allowed",
|
||||||
|
iface: domain.Interface{
|
||||||
|
Identifier: "wg0",
|
||||||
|
LdapAllowedUsers: map[string][]domain.UserIdentifier{
|
||||||
|
"ldap1": {"user1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userId: "user1",
|
||||||
|
expect: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Restricted interface - user allowed (at least one match)",
|
||||||
|
iface: domain.Interface{
|
||||||
|
Identifier: "wg0",
|
||||||
|
LdapAllowedUsers: map[string][]domain.UserIdentifier{
|
||||||
|
"ldap1": {"user2"},
|
||||||
|
"ldap2": {"user1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userId: "user1",
|
||||||
|
expect: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Restricted interface - user NOT allowed",
|
||||||
|
iface: domain.Interface{
|
||||||
|
Identifier: "wg0",
|
||||||
|
LdapAllowedUsers: map[string][]domain.UserIdentifier{
|
||||||
|
"ldap1": {"user2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userId: "user1",
|
||||||
|
expect: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expect, tt.iface.IsUserAllowed(tt.userId, cfg))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetUserInterfaces_Filtering(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Core.SelfProvisioningAllowed = true
|
||||||
|
cfg.Auth.Ldap = []config.LdapProvider{
|
||||||
|
{
|
||||||
|
ProviderName: "ldap1",
|
||||||
|
InterfaceFilter: map[string]string{
|
||||||
|
"wg_restricted": "(some-filter)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
interfaces: []domain.Interface{
|
||||||
|
{Identifier: "wg_public", Type: domain.InterfaceTypeServer},
|
||||||
|
{
|
||||||
|
Identifier: "wg_restricted",
|
||||||
|
Type: domain.InterfaceTypeServer,
|
||||||
|
LdapAllowedUsers: map[string][]domain.UserIdentifier{
|
||||||
|
"ldap1": {"allowed_user"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m := Manager{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Allowed user sees both", func(t *testing.T) {
|
||||||
|
ifaces, err := m.GetUserInterfaces(context.Background(), "allowed_user")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(ifaces))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unallowed user sees only public", func(t *testing.T) {
|
||||||
|
ifaces, err := m.GetUserInterfaces(context.Background(), "other_user")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(ifaces))
|
||||||
|
if len(ifaces) > 0 {
|
||||||
|
assert.Equal(t, domain.InterfaceIdentifier("wg_public"), ifaces[0].Identifier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
|
|||||||
|
|
||||||
currentUser := domain.GetUserInfo(ctx)
|
currentUser := domain.GetUserInfo(ctx)
|
||||||
|
|
||||||
|
if err := m.checkInterfaceAccess(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
iface, err := m.db.GetInterface(ctx, id)
|
iface, err := m.db.GetInterface(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to find interface %s: %w", id, err)
|
return nil, fmt.Errorf("unable to find interface %s: %w", id, err)
|
||||||
@@ -188,6 +192,9 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
|||||||
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
|
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionUser := domain.GetUserInfo(ctx)
|
sessionUser := domain.GetUserInfo(ctx)
|
||||||
@@ -304,6 +311,10 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := m.checkInterfaceAccess(ctx, existingPeer.InterfaceIdentifier); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
|
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
|
||||||
return nil, fmt.Errorf("update not allowed: %w", err)
|
return nil, fmt.Errorf("update not allowed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -373,6 +384,10 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.validatePeerDeletion(ctx, peer); err != nil {
|
if err := m.validatePeerDeletion(ctx, peer); err != nil {
|
||||||
return fmt.Errorf("delete not allowed: %w", err)
|
return fmt.Errorf("delete not allowed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -606,4 +621,22 @@ func (m Manager) validatePeerDeletion(ctx context.Context, _ *domain.Peer) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Manager) checkInterfaceAccess(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||||
|
user := domain.GetUserInfo(ctx)
|
||||||
|
if user.IsAdmin {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
iface, err := m.db.GetInterface(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get interface %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !iface.IsUserAllowed(user.Id, m.cfg) {
|
||||||
|
return fmt.Errorf("user %s is not allowed to access interface %s: %w", user.Id, id, domain.ErrNoPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// endregion helper-functions
|
// endregion helper-functions
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.Pin
|
|||||||
type mockDB struct {
|
type mockDB struct {
|
||||||
savedPeers map[domain.PeerIdentifier]*domain.Peer
|
savedPeers map[domain.PeerIdentifier]*domain.Peer
|
||||||
iface *domain.Interface
|
iface *domain.Interface
|
||||||
|
interfaces []domain.Interface
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
|
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
|
||||||
@@ -79,6 +80,9 @@ func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
|
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
|
||||||
|
if f.interfaces != nil {
|
||||||
|
return f.interfaces, nil
|
||||||
|
}
|
||||||
if f.iface != nil {
|
if f.iface != nil {
|
||||||
return []domain.Interface{*f.iface}, nil
|
return []domain.Interface{*f.iface}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ type LdapProvider struct {
|
|||||||
// If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
|
// If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
|
||||||
RegistrationEnabled bool `yaml:"registration_enabled"`
|
RegistrationEnabled bool `yaml:"registration_enabled"`
|
||||||
|
|
||||||
|
// InterfaceFilter allows restricting interfaces using an LDAP filter.
|
||||||
|
// Map key is the interface identifier (e.g., "wg0"), value is the filter string.
|
||||||
|
InterfaceFilter map[string]string `yaml:"interface_filter"`
|
||||||
|
|
||||||
// If LogUserInfo is set to true, the user info retrieved from the LDAP provider will be logged in trace level.
|
// If LogUserInfo is set to true, the user info retrieved from the LDAP provider will be logged in trace level.
|
||||||
LogUserInfo bool `yaml:"log_user_info"`
|
LogUserInfo bool `yaml:"log_user_info"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,33 @@ type Interface struct {
|
|||||||
PeerDefPostUp string // default action that is executed after the device is up
|
PeerDefPostUp string // default action that is executed after the device is up
|
||||||
PeerDefPreDown string // default action that is executed before the device is down
|
PeerDefPreDown string // default action that is executed before the device is down
|
||||||
PeerDefPostDown string // default action that is executed after the device is down
|
PeerDefPostDown string // default action that is executed after the device is down
|
||||||
|
|
||||||
|
// Self-provisioning access control
|
||||||
|
LdapAllowedUsers map[string][]UserIdentifier `gorm:"serializer:json"` // Materialised during LDAP sync, keyed by ProviderName
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUserAllowed returns true if the interface has no filter, or if the user is in the allowed list.
|
||||||
|
func (i *Interface) IsUserAllowed(userId UserIdentifier, cfg *config.Config) bool {
|
||||||
|
isRestricted := false
|
||||||
|
for _, provider := range cfg.Auth.Ldap {
|
||||||
|
if _, exists := provider.InterfaceFilter[string(i.Identifier)]; exists {
|
||||||
|
isRestricted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isRestricted {
|
||||||
|
return true // The interface is completely unrestricted by LDAP config
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowedUsers := range i.LdapAllowedUsers {
|
||||||
|
for _, uid := range allowedUsers {
|
||||||
|
if uid == userId {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicInfo returns a copy of the interface with only the public information.
|
// PublicInfo returns a copy of the interface with only the public information.
|
||||||
|
|||||||
Reference in New Issue
Block a user