feat: Implement LDAP interface-specific provisioning filters (#642)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled

* 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:
Jacopo Clark
2026-03-19 23:13:19 +01:00
committed by GitHub
parent f70f60a3f5
commit 402cc1b5f3
16 changed files with 339 additions and 18 deletions

View File

@@ -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)

View File

@@ -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
``` ```

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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)
@@ -50,22 +55,27 @@ type EventBus interface {
type Manager struct { type Manager struct {
cfg *config.Config cfg *config.Config
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
} }

View File

@@ -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 {

View File

@@ -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())
} }

View File

@@ -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)
}
})
}

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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"`
} }

View File

@@ -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.