feat: add simple audit ui

This commit is contained in:
Christoph Haas 2025-03-29 16:42:31 +01:00
parent a49cfa6343
commit 6cbccf6d43
23 changed files with 2098 additions and 1557 deletions

View File

@ -52,10 +52,6 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL or Post
For the complete documentation visit [wgportal.org](https://wgportal.org). For the complete documentation visit [wgportal.org](https://wgportal.org).
## V2 TODOs
* Audit UI
## What is out of scope ## What is out of scope
* Automatic generation or application of any `iptables` or `nftables` rules. * Automatic generation or application of any `iptables` or `nftables` rules.

View File

@ -71,6 +71,8 @@ func main() {
queueSize := 100 queueSize := 100
eventBus := evbus.New(queueSize) eventBus := evbus.New(queueSize)
auditManager := audit.NewManager(database)
auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database) auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database)
internal.AssertNoError(err) internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx) auditRecorder.StartBackgroundJobs(ctx)
@ -115,6 +117,7 @@ func main() {
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager) apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator) apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers) apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces) apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
@ -123,6 +126,7 @@ func main() {
apiFrontend := handlersV0.NewRestApi(apiV0Session, apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth, apiV0EndpointAuth,
apiV0EndpointAudit,
apiV0EndpointUsers, apiV0EndpointUsers,
apiV0EndpointInterfaces, apiV0EndpointInterfaces,
apiV0EndpointPeers, apiV0EndpointPeers,

View File

@ -45,16 +45,14 @@ definitions:
models.Interface: models.Interface:
properties: properties:
Addresses: Addresses:
description: Addresses is a list of IP addresses (in CIDR format) that are description: Addresses is a list of IP addresses (in CIDR format) that are assigned to the interface.
assigned to the interface.
example: example:
- 10.11.12.1/24 - 10.11.12.1/24
items: items:
type: string type: string
type: array type: array
Disabled: Disabled:
description: Disabled is a flag that specifies if the interface is enabled description: Disabled is a flag that specifies if the interface is enabled (up) or not (down). Disabled interfaces are not able to accept connections.
(up) or not (down). Disabled interfaces are not able to accept connections.
example: false example: false
type: boolean type: boolean
DisabledReason: DisabledReason:
@ -67,45 +65,38 @@ definitions:
maxLength: 64 maxLength: 64
type: string type: string
Dns: Dns:
description: Dns is a list of DNS servers that should be set if the interface description: Dns is a list of DNS servers that should be set if the interface is up.
is up.
example: example:
- 1.1.1.1 - 1.1.1.1
items: items:
type: string type: string
type: array type: array
DnsSearch: DnsSearch:
description: DnsSearch is the dns search option string that should be set description: DnsSearch is the dns search option string that should be set if the interface is up, will be appended to Dns servers.
if the interface is up, will be appended to Dns servers.
example: example:
- wg.local - wg.local
items: items:
type: string type: string
type: array type: array
EnabledPeers: EnabledPeers:
description: EnabledPeers is the number of enabled peers for this interface. description: EnabledPeers is the number of enabled peers for this interface. Only enabled peers are able to connect.
Only enabled peers are able to connect.
readOnly: true readOnly: true
type: integer type: integer
FirewallMark: FirewallMark:
description: FirewallMark is an optional firewall mark which is used to handle description: FirewallMark is an optional firewall mark which is used to handle interface traffic.
interface traffic.
type: integer type: integer
Identifier: Identifier:
description: Identifier is the unique identifier of the interface. It is always description: Identifier is the unique identifier of the interface. It is always equal to the device name of the interface.
equal to the device name of the interface.
example: wg0 example: wg0
type: string type: string
ListenPort: ListenPort:
description: 'ListenPort is the listening port, for example: 51820. The listening description: 'ListenPort is the listening port, for example: 51820. The listening port is only required for server interfaces.'
port is only required for server interfaces.'
example: 51820 example: 51820
maximum: 65535 maximum: 65535
minimum: 1 minimum: 1
type: integer type: integer
Mode: Mode:
description: Mode is the interface type, either 'server', 'client' or 'any'. description: Mode is the interface type, either 'server', 'client' or 'any'. The mode specifies how WireGuard Portal handles peers for this interface.
The mode specifies how WireGuard Portal handles peers for this interface.
enum: enum:
- server - server
- client - client
@ -119,8 +110,7 @@ definitions:
minimum: 1 minimum: 1
type: integer type: integer
PeerDefAllowedIPs: PeerDefAllowedIPs:
description: PeerDefAllowedIPs specifies the default allowed IP addresses description: PeerDefAllowedIPs specifies the default allowed IP addresses for a new peer.
for a new peer.
example: example:
- 10.11.12.0/24 - 10.11.12.0/24
items: items:
@ -134,8 +124,7 @@ definitions:
type: string type: string
type: array type: array
PeerDefDnsSearch: PeerDefDnsSearch:
description: PeerDefDnsSearch specifies the default dns search options for description: PeerDefDnsSearch specifies the default dns search options for a new peer.
a new peer.
example: example:
- wg.local - wg.local
items: items:
@ -146,64 +135,52 @@ definitions:
example: wg.example.com:51820 example: wg.example.com:51820
type: string type: string
PeerDefFirewallMark: PeerDefFirewallMark:
description: PeerDefFirewallMark specifies the default firewall mark for a description: PeerDefFirewallMark specifies the default firewall mark for a new peer.
new peer.
type: integer type: integer
PeerDefMtu: PeerDefMtu:
description: PeerDefMtu specifies the default device MTU for a new peer. description: PeerDefMtu specifies the default device MTU for a new peer.
example: 1420 example: 1420
type: integer type: integer
PeerDefNetwork: PeerDefNetwork:
description: PeerDefNetwork specifies the default subnets from which new peers description: PeerDefNetwork specifies the default subnets from which new peers will get their IP addresses. The subnet is specified in CIDR format.
will get their IP addresses. The subnet is specified in CIDR format.
example: example:
- 10.11.12.0/24 - 10.11.12.0/24
items: items:
type: string type: string
type: array type: array
PeerDefPersistentKeepalive: PeerDefPersistentKeepalive:
description: PeerDefPersistentKeepalive specifies the default persistent keep-alive description: PeerDefPersistentKeepalive specifies the default persistent keep-alive value in seconds for a new peer.
value in seconds for a new peer.
example: 25 example: 25
type: integer type: integer
PeerDefPostDown: PeerDefPostDown:
description: PeerDefPostDown specifies the default action that is executed description: PeerDefPostDown specifies the default action that is executed after the device is down for a new peer.
after the device is down for a new peer.
type: string type: string
PeerDefPostUp: PeerDefPostUp:
description: PeerDefPostUp specifies the default action that is executed after description: PeerDefPostUp specifies the default action that is executed after the device is up for a new peer.
the device is up for a new peer.
type: string type: string
PeerDefPreDown: PeerDefPreDown:
description: PeerDefPreDown specifies the default action that is executed description: PeerDefPreDown specifies the default action that is executed before the device is down for a new peer.
before the device is down for a new peer.
type: string type: string
PeerDefPreUp: PeerDefPreUp:
description: PeerDefPreUp specifies the default action that is executed before description: PeerDefPreUp specifies the default action that is executed before the device is up for a new peer.
the device is up for a new peer.
type: string type: string
PeerDefRoutingTable: PeerDefRoutingTable:
description: PeerDefRoutingTable specifies the default routing table for a description: PeerDefRoutingTable specifies the default routing table for a new peer.
new peer.
type: string type: string
PostDown: PostDown:
description: PostDown is an optional action that is executed after the device description: PostDown is an optional action that is executed after the device is down.
is down.
example: echo 'Interface is down' example: echo 'Interface is down'
type: string type: string
PostUp: PostUp:
description: PostUp is an optional action that is executed after the device description: PostUp is an optional action that is executed after the device is up.
is up.
example: iptables -A FORWARD -i %i -j ACCEPT example: iptables -A FORWARD -i %i -j ACCEPT
type: string type: string
PreDown: PreDown:
description: PreDown is an optional action that is executed before the device description: PreDown is an optional action that is executed before the device is down.
is down.
example: iptables -D FORWARD -i %i -j ACCEPT example: iptables -D FORWARD -i %i -j ACCEPT
type: string type: string
PreUp: PreUp:
description: PreUp is an optional action that is executed before the device description: PreUp is an optional action that is executed before the device is up.
is up.
example: echo 'Interface is up' example: echo 'Interface is up'
type: string type: string
PrivateKey: PrivateKey:
@ -211,17 +188,14 @@ definitions:
example: gI6EdUSYvn8ugXOt8QQD6Yc+JyiZxIhp3GInSWRfWGE= example: gI6EdUSYvn8ugXOt8QQD6Yc+JyiZxIhp3GInSWRfWGE=
type: string type: string
PublicKey: PublicKey:
description: PublicKey is the public key of the server interface. The public description: PublicKey is the public key of the server interface. The public key is used by peers to connect to the server.
key is used by peers to connect to the server.
example: HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw= example: HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=
type: string type: string
RoutingTable: RoutingTable:
description: RoutingTable is an optional routing table which is used to route description: RoutingTable is an optional routing table which is used to route interface traffic.
interface traffic.
type: string type: string
SaveConfig: SaveConfig:
description: SaveConfig is a flag that specifies if the configuration should description: SaveConfig is a flag that specifies if the configuration should be saved to the configuration file (wgX.conf in wg-quick format).
be saved to the configuration file (wgX.conf in wg-quick format).
example: false example: false
type: boolean type: boolean
TotalPeers: TotalPeers:
@ -252,8 +226,7 @@ definitions:
models.Peer: models.Peer:
properties: properties:
Addresses: Addresses:
description: Addresses is a list of IP addresses in CIDR format (both IPv4 description: Addresses is a list of IP addresses in CIDR format (both IPv4 and IPv6) for the peer.
and IPv6) for the peer.
example: example:
- 10.11.12.2/24 - 10.11.12.2/24
items: items:
@ -264,13 +237,11 @@ definitions:
- $ref: '#/definitions/models.ConfigOption-array_string' - $ref: '#/definitions/models.ConfigOption-array_string'
description: AllowedIPs is a list of allowed IP subnets for the peer. description: AllowedIPs is a list of allowed IP subnets for the peer.
CheckAliveAddress: CheckAliveAddress:
description: CheckAliveAddress is an optional ip address or DNS name that description: CheckAliveAddress is an optional ip address or DNS name that is used for ping checks.
is used for ping checks.
example: 1.1.1.1 example: 1.1.1.1
type: string type: string
Disabled: Disabled:
description: Disabled is a flag that specifies if the peer is enabled or not. description: Disabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect.
Disabled peers are not able to connect.
example: false example: false
type: boolean type: boolean
DisabledReason: DisabledReason:
@ -285,13 +256,11 @@ definitions:
Dns: Dns:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-array_string' - $ref: '#/definitions/models.ConfigOption-array_string'
description: Dns is a list of DNS servers that should be set if the peer interface description: Dns is a list of DNS servers that should be set if the peer interface is up.
is up.
DnsSearch: DnsSearch:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-array_string' - $ref: '#/definitions/models.ConfigOption-array_string'
description: DnsSearch is the dns search option string that should be set description: DnsSearch is the dns search option string that should be set if the peer interface is up, will be appended to Dns servers.
if the peer interface is up, will be appended to Dns servers.
Endpoint: Endpoint:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-string' - $ref: '#/definitions/models.ConfigOption-string'
@ -301,28 +270,23 @@ definitions:
- $ref: '#/definitions/models.ConfigOption-string' - $ref: '#/definitions/models.ConfigOption-string'
description: EndpointPublicKey is the endpoint public key. description: EndpointPublicKey is the endpoint public key.
ExpiresAt: ExpiresAt:
description: ExpiresAt is the expiry date of the peer in YYYY-MM-DD format. description: ExpiresAt is the expiry date of the peer in YYYY-MM-DD format. An expired peer is not able to connect.
An expired peer is not able to connect.
type: string type: string
ExtraAllowedIPs: ExtraAllowedIPs:
description: ExtraAllowedIPs is a list of additional allowed IP subnets for description: ExtraAllowedIPs is a list of additional allowed IP subnets for the peer. These allowed IP subnets are added on the server side.
the peer. These allowed IP subnets are added on the server side.
items: items:
type: string type: string
type: array type: array
FirewallMark: FirewallMark:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-uint32' - $ref: '#/definitions/models.ConfigOption-uint32'
description: FirewallMark is an optional firewall mark which is used to handle description: FirewallMark is an optional firewall mark which is used to handle peer traffic.
peer traffic.
Identifier: Identifier:
description: Identifier is the unique identifier of the peer. It is always description: Identifier is the unique identifier of the peer. It is always equal to the public key of the peer.
equal to the public key of the peer.
example: xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg= example: xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
type: string type: string
InterfaceIdentifier: InterfaceIdentifier:
description: InterfaceIdentifier is the identifier of the interface the peer description: InterfaceIdentifier is the identifier of the interface the peer is linked to.
is linked to.
example: wg0 example: wg0
type: string type: string
Mode: Mode:
@ -344,28 +308,23 @@ definitions:
PersistentKeepalive: PersistentKeepalive:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-int' - $ref: '#/definitions/models.ConfigOption-int'
description: PersistentKeepalive is the optional persistent keep-alive interval description: PersistentKeepalive is the optional persistent keep-alive interval in seconds.
in seconds.
PostDown: PostDown:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-string' - $ref: '#/definitions/models.ConfigOption-string'
description: PostDown is an optional action that is executed after the device description: PostDown is an optional action that is executed after the device is down.
is down.
PostUp: PostUp:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-string' - $ref: '#/definitions/models.ConfigOption-string'
description: PostUp is an optional action that is executed after the device description: PostUp is an optional action that is executed after the device is up.
is up.
PreDown: PreDown:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-string' - $ref: '#/definitions/models.ConfigOption-string'
description: PreDown is an optional action that is executed before the device description: PreDown is an optional action that is executed before the device is down.
is down.
PreUp: PreUp:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-string' - $ref: '#/definitions/models.ConfigOption-string'
description: PreUp is an optional action that is executed before the device description: PreUp is an optional action that is executed before the device is up.
is up.
PresharedKey: PresharedKey:
description: PresharedKey is the optional pre-shared Key of the peer. description: PresharedKey is the optional pre-shared Key of the peer.
example: yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk= example: yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
@ -381,8 +340,7 @@ definitions:
RoutingTable: RoutingTable:
allOf: allOf:
- $ref: '#/definitions/models.ConfigOption-string' - $ref: '#/definitions/models.ConfigOption-string'
description: RoutingTable is an optional routing table which is used to route description: RoutingTable is an optional routing table which is used to route peer traffic.
peer traffic.
UserIdentifier: UserIdentifier:
description: UserIdentifier is the identifier of the user that owns the peer. description: UserIdentifier is the identifier of the user that owns the peer.
example: uid-1234567 example: uid-1234567
@ -430,18 +388,15 @@ definitions:
models.ProvisioningRequest: models.ProvisioningRequest:
properties: properties:
InterfaceIdentifier: InterfaceIdentifier:
description: InterfaceIdentifier is the identifier of the WireGuard interface description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.
the peer should be linked to.
example: wg0 example: wg0
type: string type: string
PresharedKey: PresharedKey:
description: PresharedKey is the optional pre-shared key of the peer. If no description: PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated.
pre-shared key is set, a new key is generated.
example: yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk= example: yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
type: string type: string
PublicKey: PublicKey:
description: PublicKey is the optional public key of the peer. If no public description: PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated.
key is set, a new key pair is generated.
example: xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg= example: xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
type: string type: string
UserIdentifier: UserIdentifier:
@ -456,14 +411,12 @@ definitions:
models.User: models.User:
properties: properties:
ApiEnabled: ApiEnabled:
description: If this field is set, the user is allowed to use the RESTful description: If this field is set, the user is allowed to use the RESTful API. This field is read-only.
API. This field is read-only.
example: false example: false
readOnly: true readOnly: true
type: boolean type: boolean
ApiToken: ApiToken:
description: The API token of the user. This field is never populated on bulk description: The API token of the user. This field is never populated on bulk read operations.
read operations.
example: "" example: ""
maxLength: 64 maxLength: 64
minLength: 32 minLength: 32
@ -502,8 +455,7 @@ definitions:
example: Muster example: Muster
type: string type: string
Locked: Locked:
description: If this field is set, the user is locked and thus unable to log description: If this field is set, the user is locked and thus unable to log in to WireGuard Portal.
in to WireGuard Portal.
example: false example: false
type: boolean type: boolean
LockedReason: LockedReason:
@ -515,8 +467,7 @@ definitions:
example: some sample notes example: some sample notes
type: string type: string
Password: Password:
description: The password of the user. This field is never populated on read description: The password of the user. This field is never populated on read operations.
operations.
example: "" example: ""
maxLength: 64 maxLength: 64
minLength: 16 minLength: 16
@ -567,39 +518,33 @@ definitions:
example: My iPhone example: My iPhone
type: string type: string
Identifier: Identifier:
description: Identifier is the unique identifier of the peer. It equals the description: Identifier is the unique identifier of the peer. It equals the public key of the peer.
public key of the peer.
example: peer-1234567 example: peer-1234567
type: string type: string
InterfaceIdentifier: InterfaceIdentifier:
description: InterfaceIdentifier is the unique identifier of the WireGuard description: InterfaceIdentifier is the unique identifier of the WireGuard Portal device the peer is connected to.
Portal device the peer is connected to.
example: wg0 example: wg0
type: string type: string
IpAddresses: IpAddresses:
description: IPAddresses is a list of IP addresses in CIDR format assigned description: IPAddresses is a list of IP addresses in CIDR format assigned to the peer.
to the peer.
example: example:
- 10.11.12.2/24 - 10.11.12.2/24
items: items:
type: string type: string
type: array type: array
IsDisabled: IsDisabled:
description: IsDisabled is a flag that specifies if the peer is enabled or description: IsDisabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect.
not. Disabled peers are not able to connect.
example: true example: true
type: boolean type: boolean
type: object type: object
models.UserMetrics: models.UserMetrics:
properties: properties:
BytesReceived: BytesReceived:
description: The total number of bytes received by the user. This is the sum description: The total number of bytes received by the user. This is the sum of all bytes received by the peers linked to the user.
of all bytes received by the peers linked to the user.
example: 123456789 example: 123456789
type: integer type: integer
BytesTransmitted: BytesTransmitted:
description: The total number of bytes transmitted by the user. This is the description: The total number of bytes transmitted by the user. This is the sum of all bytes transmitted by the peers linked to the user.
sum of all bytes transmitted by the peers linked to the user.
example: 123456789 example: 123456789
type: integer type: integer
PeerCount: PeerCount:
@ -607,8 +552,7 @@ definitions:
example: 2 example: 2
type: integer type: integer
PeerMetrics: PeerMetrics:
description: PeerMetrics represents the metrics of the peers linked to the description: PeerMetrics represents the metrics of the peers linked to the user.
user.
items: items:
$ref: '#/definitions/models.PeerMetrics' $ref: '#/definitions/models.PeerMetrics'
type: array type: array
@ -970,8 +914,7 @@ paths:
tags: tags:
- Peers - Peers
get: get:
description: Normal users can only access their own records. Admins can access description: Normal users can only access their own records. Admins can access all records.
all records.
operationId: peers_handleByIdGet operationId: peers_handleByIdGet
parameters: parameters:
- description: The peer identifier (public key). - description: The peer identifier (public key).
@ -1087,8 +1030,7 @@ paths:
- Peers - Peers
/peer/by-user/{id}: /peer/by-user/{id}:
get: get:
description: Normal users can only access their own records. Admins can access description: Normal users can only access their own records. Admins can access all records.
all records.
operationId: peers_handleAllForUserGet operationId: peers_handleAllForUserGet
parameters: parameters:
- description: The user identifier. - description: The user identifier.
@ -1163,8 +1105,7 @@ paths:
- Peers - Peers
/provisioning/data/peer-config: /provisioning/data/peer-config:
get: get:
description: Normal users can only access their own record. Admins can access description: Normal users can only access their own record. Admins can access all records.
all records.
operationId: provisioning_handlePeerConfigGet operationId: provisioning_handlePeerConfigGet
parameters: parameters:
- description: The peer identifier (public key) that should be queried. - description: The peer identifier (public key) that should be queried.
@ -1207,8 +1148,7 @@ paths:
- Provisioning - Provisioning
/provisioning/data/peer-qr: /provisioning/data/peer-qr:
get: get:
description: Normal users can only access their own record. Admins can access description: Normal users can only access their own record. Admins can access all records.
all records.
operationId: provisioning_handlePeerQrGet operationId: provisioning_handlePeerQrGet
parameters: parameters:
- description: The peer identifier (public key) that should be queried. - description: The peer identifier (public key) that should be queried.
@ -1251,17 +1191,14 @@ paths:
- Provisioning - Provisioning
/provisioning/data/user-info: /provisioning/data/user-info:
get: get:
description: Normal users can only access their own record. Admins can access description: Normal users can only access their own record. Admins can access all records.
all records.
operationId: provisioning_handleUserInfoGet operationId: provisioning_handleUserInfoGet
parameters: parameters:
- description: The user identifier that should be queried. If not set, the authenticated - description: The user identifier that should be queried. If not set, the authenticated user is used.
user is used.
in: query in: query
name: UserId name: UserId
type: string type: string
- description: The email address that should be queried. If UserId is set, this - description: The email address that should be queried. If UserId is set, this is ignored.
is ignored.
in: query in: query
name: Email name: Email
type: string type: string
@ -1299,8 +1236,7 @@ paths:
- Provisioning - Provisioning
/provisioning/new-peer: /provisioning/new-peer:
post: post:
description: Normal users can only create new peers if self provisioning is description: Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.
allowed. Admins can always add new peers.
operationId: provisioning_handleNewPeerPost operationId: provisioning_handleNewPeerPost
parameters: parameters:
- description: Provisioning request model. - description: Provisioning request model.
@ -1406,8 +1342,7 @@ paths:
tags: tags:
- Users - Users
get: get:
description: Normal users can only access their own record. Admins can access description: Normal users can only access their own record. Admins can access all records.
all records.
operationId: users_handleByIdGet operationId: users_handleByIdGet
parameters: parameters:
- description: The user identifier. - description: The user identifier.

View File

@ -91,6 +91,7 @@ const currentYear = ref(new Date().getFullYear())
<div class="dropdown-menu"> <div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink> <RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink> <RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
<RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a> <a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>
</div> </div>

View File

@ -38,6 +38,7 @@
"lang": "Toggle Language", "lang": "Toggle Language",
"profile": "My Profile", "profile": "My Profile",
"settings": "Settings", "settings": "Settings",
"audit": "Audit Log",
"login": "Login", "login": "Login",
"logout": "Logout" "logout": "Logout"
}, },
@ -188,6 +189,23 @@
"api-link": "API Documentation" "api-link": "API Documentation"
} }
}, },
"audit": {
"headline": "Audit Log",
"abstract": "Here you can find the audit log of all actions performed in the WireGuard Portal.",
"no-entries": {
"headline": "No log entries available",
"abstract": "Currently, there are no audit logs recorded."
},
"entries-headline": "Log Entries",
"table-heading": {
"id": "#",
"time": "Time",
"user": "User",
"severity": "Severity",
"origin": "Origin",
"message": "Message"
}
},
"modals": { "modals": {
"user-view": { "user-view": {
"headline": "User Account:", "headline": "User Account:",

View File

@ -56,6 +56,14 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route // this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited. // which is lazy-loaded when the route is visited.
component: () => import('../views/SettingsView.vue') component: () => import('../views/SettingsView.vue')
},
{
path: '/audit',
name: 'audit',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AuditView.vue')
} }
], ],
linkActiveClass: "active", linkActiveClass: "active",

View File

@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/audit`
export const auditStore = defineStore('audit', {
state: () => ({
entries: [],
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Count: (state) => state.entries.length,
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.entries,
Filtered: (state) => {
if (!state.filter) {
return state.entries
}
return state.entries.filter((e) => {
return e.Timestamp.includes(state.filter) ||
e.Message.includes(state.filter) ||
e.Severity.includes(state.filter) ||
e.Origin.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setEntries(entries) {
this.entries = entries
this.calculatePages()
this.fetching = false
},
async LoadEntries() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/entries`)
.then(this.setEntries)
.catch(error => {
this.setEntries([])
console.log("Failed to load audit entries: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load audit entries!",
})
})
},
}
})

View File

@ -0,0 +1,96 @@
<script setup>
import { onMounted } from "vue";
import {auditStore} from "@/stores/audit";
const audit = auditStore()
onMounted(async () => {
await audit.LoadEntries()
})
</script>
<template>
<div class="page-header">
<h1>{{ $t('audit.headline') }}</h1>
</div>
<p class="lead">{{ $t('audit.abstract') }}</p>
<!-- Entry list -->
<div class="mt-4 row">
<div class="col-12 col-lg-6">
<h3>{{ $t('audit.entries-headline') }}</h3>
</div>
<div class="col-12 col-lg-6 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="audit.Count===0">
<h4>{{ $t('audit.no-entries.headline') }}</h4>
<p>{{ $t('audit.no-entries.abstract') }}</p>
</div>
<table v-if="audit.Count!==0" id="auditTable" class="table table-sm">
<thead>
<tr>
<th scope="col">{{ $t('audit.table-heading.id') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.time') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.severity') }}</th>
<th scope="col">{{ $t('audit.table-heading.user') }}</th>
<th scope="col">{{ $t('audit.table-heading.origin') }}</th>
<th scope="col">{{ $t('audit.table-heading.message') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in audit.FilteredAndPaged" :key="entry.Id">
<td>{{entry.Id}}</td>
<td>{{entry.Timestamp}}</td>
<td class="text-center"><span class="badge rounded-pill" :class="[ entry.Severity === 'low' ? 'bg-light' : entry.Severity === 'medium' ? 'bg-warning' : 'bg-danger']">{{entry.Severity}}</span></td>
<td>{{entry.ContextUser}}</td>
<td>{{entry.Origin}}</td>
<td>{{entry.Message}}</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:audit.pageOffset===0}" class="page-item">
<a class="page-link" @click="audit.previousPage">&laquo;</a>
</li>
<li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item">
<a class="page-link" @click="audit.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!audit.hasNextPage}" class="page-item">
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,13 +1,8 @@
<script setup> <script setup>
import PeerViewModal from "../components/PeerViewModal.vue"; import { onMounted } from "vue";
import { onMounted, ref } from "vue";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import PeerEditModal from "@/components/PeerEditModal.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { authStore } from "../stores/auth";
import {RouterLink} from "vue-router";
import {authStore} from "../stores/auth";
const profile = profileStore() const profile = profileStore()
const settings = settingsStore() const settings = settingsStore()

View File

@ -3,16 +3,12 @@ import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue"; import {ref,onMounted} from "vue";
import UserEditModal from "../components/UserEditModal.vue"; import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue"; import UserViewModal from "../components/UserViewModal.vue";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const users = userStore() const users = userStore()
const editUserId = ref("") const editUserId = ref("")
const viewedUserId = ref("") const viewedUserId = ref("")
const selectAll = ref(false) const selectAll = ref(false)
function toggleSelectAll() { function toggleSelectAll() {

View File

@ -1049,4 +1049,16 @@ func (r *SqlRepo) SaveAuditEntry(ctx context.Context, entry *domain.AuditEntry)
return nil return nil
} }
// GetAllAuditEntries retrieves all audit entries from the database.
// The entries are ordered by timestamp, with the newest entries first.
func (r *SqlRepo) GetAllAuditEntries(ctx context.Context) ([]domain.AuditEntry, error) {
var entries []domain.AuditEntry
err := r.db.WithContext(ctx).Order("created_at desc").Find(&entries).Error
if err != nil {
return nil, err
}
return entries, nil
}
// endregion audit // endregion audit

View File

@ -11,6 +11,29 @@
}, },
"basePath": "/api/v0", "basePath": "/api/v0",
"paths": { "paths": {
"/audit/entries": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Audit"
],
"summary": "Get all available audit entries. Ordered by timestamp.",
"operationId": "audit_handleEntriesGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.AuditEntry"
}
}
}
}
}
},
"/auth/login": { "/auth/login": {
"post": { "post": {
"produces": [ "produces": [
@ -171,6 +194,9 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
"500": {
"description": "Internal Server Error"
} }
} }
} }
@ -1494,6 +1520,30 @@
} }
}, },
"definitions": { "definitions": {
"model.AuditEntry": {
"type": "object",
"properties": {
"Message": {
"type": "string"
},
"ctx_user": {
"type": "string"
},
"id": {
"type": "integer"
},
"origin": {
"description": "origin: for example user auth, stats, ...",
"type": "string"
},
"severity": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"model.ConfigOption-array_string": { "model.ConfigOption-array_string": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1,5 +1,21 @@
basePath: /api/v0 basePath: /api/v0
definitions: definitions:
model.AuditEntry:
properties:
Message:
type: string
ctx_user:
type: string
id:
type: integer
origin:
description: 'origin: for example user auth, stats, ...'
type: string
severity:
type: string
timestamp:
type: string
type: object
model.ConfigOption-array_string: model.ConfigOption-array_string:
properties: properties:
Overridable: Overridable:
@ -419,6 +435,21 @@ info:
title: WireGuard Portal SPA-UI API title: WireGuard Portal SPA-UI API
version: "0.0" version: "0.0"
paths: paths:
/audit/entries:
get:
operationId: audit_handleEntriesGet
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.AuditEntry'
type: array
summary: Get all available audit entries. Ordered by timestamp.
tags:
- Audit
/auth/{provider}/callback: /auth/{provider}/callback:
get: get:
operationId: auth_handleOauthCallbackGet operationId: auth_handleOauthCallbackGet
@ -523,6 +554,8 @@ paths:
description: The JavaScript contents description: The JavaScript contents
schema: schema:
type: string type: string
"500":
description: Internal Server Error
summary: Get the dynamic frontend configuration javascript. summary: Get the dynamic frontend configuration javascript.
tags: tags:
- Configuration - Configuration

View File

@ -0,0 +1,69 @@
package handlers
import (
"context"
"net/http"
"github.com/go-pkgz/routegroup"
"github.com/h44z/wg-portal/internal/app/api/core/respond"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type AuditService interface {
// GetAll returns all audit entries ordered by timestamp. Newest first.
GetAll(ctx context.Context) ([]domain.AuditEntry, error)
}
type AuditEndpoint struct {
cfg *config.Config
authenticator Authenticator
auditService AuditService
}
func NewAuditEndpoint(
cfg *config.Config,
authenticator Authenticator,
auditService AuditService,
) AuditEndpoint {
return AuditEndpoint{
cfg: cfg,
authenticator: authenticator,
auditService: auditService,
}
}
func (e AuditEndpoint) GetName() string {
return "AuditEndpoint"
}
func (e AuditEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/audit")
apiGroup.Use(e.authenticator.LoggedIn(ScopeAdmin))
apiGroup.HandleFunc("GET /entries", e.handleEntriesGet())
}
// handleExternalLoginProvidersGet returns a gorm Handler function.
//
// @ID audit_handleEntriesGet
// @Tags Audit
// @Summary Get all available audit entries. Ordered by timestamp.
// @Produce json
// @Success 200 {object} []model.AuditEntry
// @Router /audit/entries [get]
func (e AuditEndpoint) handleEntriesGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
providers, err := e.auditService.GetAll(r.Context())
if err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
})
return
}
respond.JSON(w, http.StatusOK, model.NewAuditEntries(providers))
}
}

View File

@ -0,0 +1,36 @@
package model
import (
"github.com/h44z/wg-portal/internal/domain"
)
type AuditEntry struct {
Id uint64 `json:"Id"`
Timestamp string `json:"Timestamp"`
ContextUser string `json:"ContextUser"`
Severity string `json:"Severity"`
Origin string `json:"Origin"` // origin: for example user auth, stats, ...
Message string `message:"Message"`
}
// NewAuditEntry creates a REST API AuditEntry from a domain AuditEntry.
func NewAuditEntry(src domain.AuditEntry) AuditEntry {
return AuditEntry{
Id: src.UniqueId,
Timestamp: src.CreatedAt.Format("2006-01-02 15:04:05"),
ContextUser: src.ContextUser,
Severity: string(src.Severity),
Origin: src.Origin,
Message: src.Message,
}
}
// NewAuditEntries creates a slice of REST API AuditEntry from a slice of domain AuditEntry.
func NewAuditEntries(src []domain.AuditEntry) []AuditEntry {
dst := make([]AuditEntry, 0, len(src))
for _, entry := range src {
dst = append(dst, NewAuditEntry(entry))
}
return dst
}

View File

@ -0,0 +1,37 @@
package audit
import (
"context"
"fmt"
"github.com/h44z/wg-portal/internal/domain"
)
type ManagerDatabaseRepo interface {
// GetAllAuditEntries retrieves all audit entries from the database.
// The entries are ordered by timestamp, with the newest entries first.
GetAllAuditEntries(ctx context.Context) ([]domain.AuditEntry, error)
}
type Manager struct {
db ManagerDatabaseRepo
}
func NewManager(db ManagerDatabaseRepo) *Manager {
return &Manager{db: db}
}
func (m *Manager) GetAll(ctx context.Context) ([]domain.AuditEntry, error) {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return nil, domain.ErrNoPermission
}
entries, err := m.db.GetAllAuditEntries(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query audit entries: %w", err)
}
return entries, nil
}

View File

@ -0,0 +1,18 @@
package audit
import "github.com/h44z/wg-portal/internal/domain"
type AuthEvent struct {
Username string
Error string
}
type InterfaceEvent struct {
Interface domain.Interface
Action string
}
type PeerEvent struct {
Peer domain.Peer
Action string
}

View File

@ -78,22 +78,98 @@ func (r *Recorder) connectToMessageBus() error {
return nil // noting to do return nil // noting to do
} }
if err := r.bus.Subscribe(app.TopicAuthLogin, r.authLoginEvent); err != nil { if err := r.bus.Subscribe(app.TopicAuditLoginSuccess, r.handleAuthEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuthLogin, err) return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditLoginSuccess, err)
}
if err := r.bus.Subscribe(app.TopicAuditLoginFailed, r.handleAuthEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditLoginFailed, err)
}
if err := r.bus.Subscribe(app.TopicAuditInterfaceChanged, r.handleInterfaceEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditInterfaceChanged, err)
}
if err := r.bus.Subscribe(app.TopicAuditPeerChanged, r.handleInterfaceEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditPeerChanged, err)
} }
return nil return nil
} }
func (r *Recorder) authLoginEvent(userIdentifier domain.UserIdentifier) { func (r *Recorder) handleAuthEvent(event domain.AuditEventWrapper[AuthEvent]) {
err := r.db.SaveAuditEntry(context.Background(), &domain.AuditEntry{ err := r.db.SaveAuditEntry(context.Background(), r.authEventToAuditEntry(event))
CreatedAt: time.Time{},
Severity: domain.AuditSeverityLevelLow,
Origin: "authLoginEvent",
Message: fmt.Sprintf("user %s logged in", userIdentifier),
})
if err != nil { if err != nil {
slog.Error("failed to create audit entry for handleAuthLoginEvent", "error", err) slog.Error("failed to create audit entry for auth event", "error", err)
return return
} }
} }
func (r *Recorder) handleInterfaceEvent(event domain.AuditEventWrapper[InterfaceEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.interfaceEventToAuditEntry(event))
if err != nil {
slog.Error("failed to create audit entry for interface event", "error", err)
return
}
}
func (r *Recorder) handlePeerEvent(event domain.AuditEventWrapper[PeerEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.peerEventToAuditEntry(event))
if err != nil {
slog.Error("failed to create audit entry for peer event", "error", err)
return
}
}
func (r *Recorder) authEventToAuditEntry(event domain.AuditEventWrapper[AuthEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("auth: %s", event.Source),
Message: fmt.Sprintf("%s logged in", event.Event.Username),
}
if event.Event.Error != "" {
e.Severity = domain.AuditSeverityLevelHigh
e.Message = fmt.Sprintf("%s failed to login: %s", event.Event.Username, event.Event.Error)
}
return &e
}
func (r *Recorder) interfaceEventToAuditEntry(event domain.AuditEventWrapper[InterfaceEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("interface: %s", event.Event.Action),
}
switch event.Event.Action {
case "save":
e.Message = fmt.Sprintf("%s updated", event.Event.Interface.Identifier)
default:
e.Message = fmt.Sprintf("%s: unknown action", event.Event.Interface.Identifier)
}
return &e
}
func (r *Recorder) peerEventToAuditEntry(event domain.AuditEventWrapper[PeerEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("peer: %s", event.Event.Action),
}
switch event.Event.Action {
case "save":
e.Message = fmt.Sprintf("%s updated", event.Event.Peer.Identifier)
default:
e.Message = fmt.Sprintf("%s: unknown action", event.Event.Peer.Identifier)
}
return &e
}

View File

@ -17,6 +17,7 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -245,10 +246,24 @@ func (a *Authenticator) PlainLogin(ctx context.Context, username, password strin
user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password) user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password)
if err != nil { if err != nil {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "plain",
Event: audit.AuthEvent{
Username: username, Error: err.Error(),
},
})
return nil, fmt.Errorf("login failed: %w", err) return nil, fmt.Errorf("login failed: %w", err)
} }
a.bus.Publish(app.TopicAuthLogin, user.Identifier) a.bus.Publish(app.TopicAuthLogin, user.Identifier)
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "plain",
Event: audit.AuthEvent{
Username: string(user.Identifier),
},
})
return user, nil return user, nil
} }
@ -405,14 +420,37 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(), user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(),
oauthProvider.RegistrationEnabled()) oauthProvider.RegistrationEnabled())
if err != nil { if err != nil {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(userInfo.Identifier),
Error: err.Error(),
},
})
return nil, fmt.Errorf("unable to process user information: %w", err) return nil, fmt.Errorf("unable to process user information: %w", err)
} }
if user.IsLocked() || user.IsDisabled() { if user.IsLocked() || user.IsDisabled() {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(user.Identifier),
Error: "user is locked",
},
})
return nil, errors.New("user is locked") return nil, errors.New("user is locked")
} }
a.bus.Publish(app.TopicAuthLogin, user.Identifier) a.bus.Publish(app.TopicAuthLogin, user.Identifier)
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(user.Identifier),
},
})
return user, nil return user, nil
} }

View File

@ -13,3 +13,9 @@ const TopicRouteRemove = "route:remove"
const TopicInterfaceUpdated = "interface:updated" const TopicInterfaceUpdated = "interface:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated" const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated" const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicAuditLoginSuccess = "audit:login:success"
const TopicAuditLoginFailed = "audit:login:failed"
const TopicAuditInterfaceChanged = "audit:interface:changed"
const TopicAuditPeerChanged = "audit:peer:changed"

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -549,6 +550,13 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
} }
m.bus.Publish(app.TopicInterfaceUpdated, iface) m.bus.Publish(app.TopicInterfaceUpdated, iface)
m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
Ctx: ctx,
Event: audit.InterfaceEvent{
Interface: *iface,
Action: "save",
},
})
return iface, nil return iface, nil
} }

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -426,6 +427,15 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err) return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err)
} }
// publish event
m.bus.Publish(app.TopicAuditPeerChanged, domain.AuditEventWrapper[audit.PeerEvent]{
Ctx: ctx,
Event: audit.PeerEvent{
Action: "save",
Peer: *peer,
},
})
interfaces[peer.InterfaceIdentifier] = struct{}{} interfaces[peer.InterfaceIdentifier] = struct{}{}
} }

View File

@ -1,18 +1,30 @@
package domain package domain
import "time" import (
"context"
"time"
)
type AuditSeverityLevel string type AuditSeverityLevel string
const AuditSeverityLevelLow AuditSeverityLevel = "low" const AuditSeverityLevelLow AuditSeverityLevel = "low"
const AuditSeverityLevelHigh AuditSeverityLevel = "high"
type AuditEntry struct { type AuditEntry struct {
UniqueId uint64 `gorm:"primaryKey;autoIncrement:true;column:id"` UniqueId uint64 `gorm:"primaryKey;autoIncrement:true;column:id"`
CreatedAt time.Time `gorm:"column:created_at;index:idx_au_created"` CreatedAt time.Time `gorm:"column:created_at;index:idx_au_created"`
ContextUser string `gorm:"column:context_user;index:idx_au_context_user"`
Severity AuditSeverityLevel `gorm:"column:severity;index:idx_au_severity"` Severity AuditSeverityLevel `gorm:"column:severity;index:idx_au_severity"`
Origin string `gorm:"column:origin"` // origin: for example user auth, stats, ... Origin string `gorm:"column:origin"` // origin: for example user auth, stats, ...
Message string `gorm:"column:message"` Message string `gorm:"column:message"`
} }
type AuditEventWrapper[T any] struct {
Ctx context.Context
Source string
Event T
}