@@ -174,7 +175,7 @@ onMounted(async () => {
-
|
diff --git a/internal/app/users/ldap_sync.go b/internal/app/users/ldap_sync.go
index f3215cc..cc0fff9 100644
--- a/internal/app/users/ldap_sync.go
+++ b/internal/app/users/ldap_sync.go
@@ -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
}
@@ -237,3 +243,59 @@ func (m Manager) disableMissingLdapUsers(
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
+}
diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go
index a3aae07..447fc9d 100644
--- a/internal/app/users/user_manager.go
+++ b/internal/app/users/user_manager.go
@@ -39,6 +39,11 @@ type PeerDatabaseRepo interface {
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 {
// Publish sends a message to the message bus.
Publish(topic string, args ...any)
@@ -50,22 +55,27 @@ type EventBus interface {
type Manager struct {
cfg *config.Config
- bus EventBus
- users UserDatabaseRepo
- peers PeerDatabaseRepo
+ bus EventBus
+ users UserDatabaseRepo
+ peers PeerDatabaseRepo
+ interfaces InterfaceDatabaseRepo
}
// NewUserManager creates a new user manager instance.
-func NewUserManager(cfg *config.Config, bus EventBus, users UserDatabaseRepo, peers PeerDatabaseRepo) (
- *Manager,
- error,
-) {
+func NewUserManager(
+ cfg *config.Config,
+ bus EventBus,
+ users UserDatabaseRepo,
+ peers PeerDatabaseRepo,
+ interfaces InterfaceDatabaseRepo,
+) (*Manager, error) {
m := &Manager{
cfg: cfg,
bus: bus,
- users: users,
- peers: peers,
+ users: users,
+ peers: peers,
+ interfaces: interfaces,
}
return m, nil
}
diff --git a/internal/app/wireguard/wireguard.go b/internal/app/wireguard/wireguard.go
index e1e9dfa..c564240 100644
--- a/internal/app/wireguard/wireguard.go
+++ b/internal/app/wireguard/wireguard.go
@@ -35,6 +35,7 @@ type InterfaceAndPeerDatabaseRepo interface {
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, 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 {
diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go
index 77f6f96..455c2fd 100644
--- a/internal/app/wireguard/wireguard_interfaces.go
+++ b/internal/app/wireguard/wireguard_interfaces.go
@@ -16,6 +16,11 @@ import (
"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.
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*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.
// 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, _ domain.UserIdentifier) ([]domain.Interface, error) {
+func (m Manager) GetUserInterfaces(ctx context.Context, userId domain.UserIdentifier) ([]domain.Interface, error) {
if !m.cfg.Core.SelfProvisioningAllowed {
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)
if err != nil {
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 {
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())
}
diff --git a/internal/app/wireguard/wireguard_interfaces_test.go b/internal/app/wireguard/wireguard_interfaces_test.go
index 95f202b..80e56e0 100644
--- a/internal/app/wireguard/wireguard_interfaces_test.go
+++ b/internal/app/wireguard/wireguard_interfaces_test.go
@@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/assert"
+ "github.com/h44z/wg-portal/internal/config"
"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)
+ }
+ })
+}
diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go
index 46dfef3..4232942 100644
--- a/internal/app/wireguard/wireguard_peers.go
+++ b/internal/app/wireguard/wireguard_peers.go
@@ -93,6 +93,10 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
currentUser := domain.GetUserInfo(ctx)
+ if err := m.checkInterfaceAccess(ctx, id); err != nil {
+ return nil, err
+ }
+
iface, err := m.db.GetInterface(ctx, id)
if err != nil {
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 {
return nil, err
}
+ if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
+ return nil, err
+ }
}
sessionUser := domain.GetUserInfo(ctx)
@@ -304,6 +311,10 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
return nil, err
}
+ if err := m.checkInterfaceAccess(ctx, existingPeer.InterfaceIdentifier); err != nil {
+ return nil, err
+ }
+
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
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
}
+ if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
+ return err
+ }
+
if err := m.validatePeerDeletion(ctx, peer); err != nil {
return fmt.Errorf("delete not allowed: %w", err)
}
@@ -606,4 +621,22 @@ func (m Manager) validatePeerDeletion(ctx context.Context, _ *domain.Peer) error
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
diff --git a/internal/app/wireguard/wireguard_peers_test.go b/internal/app/wireguard/wireguard_peers_test.go
index 8a08122..8ebb4b4 100644
--- a/internal/app/wireguard/wireguard_peers_test.go
+++ b/internal/app/wireguard/wireguard_peers_test.go
@@ -60,6 +60,7 @@ func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.Pin
type mockDB struct {
savedPeers map[domain.PeerIdentifier]*domain.Peer
iface *domain.Interface
+ interfaces []domain.Interface
}
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
}
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
+ if f.interfaces != nil {
+ return f.interfaces, nil
+ }
if f.iface != nil {
return []domain.Interface{*f.iface}, nil
}
diff --git a/internal/config/auth.go b/internal/config/auth.go
index 4314b63..a3c6f5a 100644
--- a/internal/config/auth.go
+++ b/internal/config/auth.go
@@ -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.
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.
LogUserInfo bool `yaml:"log_user_info"`
}
diff --git a/internal/domain/interface.go b/internal/domain/interface.go
index d8e4f0a..2efd5bf 100644
--- a/internal/domain/interface.go
+++ b/internal/domain/interface.go
@@ -78,6 +78,33 @@ type Interface struct {
PeerDefPostUp string // default action that is executed after the device is up
PeerDefPreDown string // default action that is executed before 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.