diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 08a93c4..2569ffc 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -155,50 +155,6 @@
-
-
-
-
- true
- true
- true
-
-
-
-
-
-
-
-
- true
- true
- true
-
-
-
-
-
-
-
-
- true
- true
- true
-
-
-
-
-
-
-
-
- true
- true
- true
-
-
-
-
-
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/API.java b/src/main/java/pro/cloudnode/smp/bankaccounts/API.java
index 37391e6..16523bf 100644
--- a/src/main/java/pro/cloudnode/smp/bankaccounts/API.java
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/API.java
@@ -17,6 +17,7 @@
import org.jetbrains.annotations.NotNull;
import pro.cloudnode.smp.bankaccounts.api.account.AccountsService;
+import pro.cloudnode.smp.bankaccounts.api.acl.AclService;
import pro.cloudnode.smp.bankaccounts.api.ledger.LedgerService;
import javax.sql.DataSource;
@@ -36,6 +37,11 @@ public final class API {
*/
public final @NotNull LedgerService ledger;
+ /**
+ * Service for managing access control lists (ACLs) and relations.
+ */
+ public final @NotNull AclService acl;
+
API(
final @NotNull Logger parentLogger,
final @NotNull DataSource dataSource,
@@ -44,5 +50,6 @@ public final class API {
) {
this.accounts = new AccountsService(parentLogger, dataSource, idLengthAccount);
this.ledger = new LedgerService(parentLogger, dataSource, accounts, idLengthTransaction);
+ this.acl = new AclService(parentLogger, dataSource);
}
}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/Serializable.java b/src/main/java/pro/cloudnode/smp/bankaccounts/Serializable.java
new file mode 100644
index 0000000..41e46e4
--- /dev/null
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/Serializable.java
@@ -0,0 +1,30 @@
+/*
+ * BankAccounts is a Minecraft economy plugin that enables players to hold multiple bank accounts.
+ * Copyright © 2023–2026 Cloudnode OÜ.
+ *
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with this program. If not, see
+ * .
+ */
+
+package pro.cloudnode.smp.bankaccounts;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Represents an object that can be serialised to a string.
+ */
+public interface Serializable {
+ /**
+ * Serialises this object to a string.
+ *
+ * @return the serialised string representation
+ */
+ @NotNull String serialize();
+}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/TypedIdentifier.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/TypedIdentifier.java
new file mode 100644
index 0000000..6f0d9ef
--- /dev/null
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/TypedIdentifier.java
@@ -0,0 +1,135 @@
+/*
+ * BankAccounts is a Minecraft economy plugin that enables players to hold multiple bank accounts.
+ * Copyright © 2023–2026 Cloudnode OÜ.
+ *
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with this program. If not, see
+ * .
+ */
+
+package pro.cloudnode.smp.bankaccounts.api;
+
+import org.jetbrains.annotations.NotNull;
+import pro.cloudnode.smp.bankaccounts.Serializable;
+
+import java.util.Objects;
+
+/**
+ * Represents a typed identifier.
+ */
+public class TypedIdentifier implements Serializable {
+ private final @NotNull Type type;
+ private final @NotNull String id;
+
+ /**
+ * Constructs a typed identifier.
+ *
+ * @param type the type
+ * @param id the identifier
+ */
+ public TypedIdentifier(final @NotNull Type type, final @NotNull String id) {
+ this.type = type;
+ this.id = id;
+ }
+
+ /**
+ * Parses a typed identifier from a string in the format {@code :}.
+ *
+ * @param identifier the identifier string to parse
+ * @return the corresponding typed identifier
+ * @throws IllegalArgumentException if the identifier format is invalid or the type is unknown
+ */
+ @NotNull
+ public static TypedIdentifier deserialize(final @NotNull String identifier) {
+ final int colonIndex = identifier.indexOf(':');
+ if (colonIndex == -1) {
+ throw new IllegalArgumentException(String.format("Invalid identifier: %s", identifier));
+ }
+ return new TypedIdentifier(
+ Type.deserialize(identifier.substring(0, colonIndex)),
+ identifier.substring(colonIndex + 1)
+ );
+ }
+
+ /**
+ * Returns a string representation of this typed identifier in the format {@code :}.
+ *
+ * @return the identifier string representation
+ */
+ @Override
+ @NotNull
+ public String serialize() {
+ return type.toString() + ':' + id;
+ }
+
+ /**
+ * Returns the identifier type.
+ *
+ * @return the type
+ */
+ public @NotNull Type type() {
+ return type;
+ }
+
+ /**
+ * Returns the identifier value.
+ *
+ * @return the identifier
+ */
+ public @NotNull String id() {
+ return id;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof final TypedIdentifier ti)) {
+ return false;
+ }
+ return this.type == ti.type && this.id.equals(ti.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(type.serialize(), id);
+ }
+
+ /**
+ * Represents an identifier type.
+ */
+ public enum Type implements Serializable {
+ /**
+ * Represents an account holder.
+ */
+ HOLDER,
+
+ /**
+ * Represents an account.
+ */
+ ACCOUNT;
+
+ /**
+ * Returns a type from a string.
+ *
+ * @param value the string representation of the type
+ * @return the type
+ */
+ @NotNull
+ public static Type deserialize(final @NotNull String value) {
+ return valueOf(value.toUpperCase());
+ }
+
+ @Override
+ public @NotNull String serialize() {
+ return name().toLowerCase();
+ }
+ }
+}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/Account.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/Account.java
index 4ead52d..b74932e 100644
--- a/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/Account.java
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/Account.java
@@ -25,7 +25,7 @@
* Represents a bank account.
*/
public final class Account {
- private final @NotNull String id;
+ private final @NotNull AccountId id;
private final @NotNull Type type;
private final @NotNull Instant created;
private boolean allowNegative;
@@ -33,7 +33,7 @@ public final class Account {
private @Nullable String name;
Account(
- final @NotNull String id,
+ final @NotNull AccountId id,
final @NotNull Type type,
final boolean allowNegative,
final @NotNull Status status,
@@ -54,7 +54,7 @@ public final class Account {
* @return the account ID
*/
@NotNull
- public String id() {
+ public AccountId id() {
return id;
}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountId.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountId.java
new file mode 100644
index 0000000..dce4c42
--- /dev/null
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountId.java
@@ -0,0 +1,33 @@
+/*
+ * BankAccounts is a Minecraft economy plugin that enables players to hold multiple bank accounts.
+ * Copyright © 2023–2026 Cloudnode OÜ.
+ *
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with this program. If not, see
+ * .
+ */
+
+package pro.cloudnode.smp.bankaccounts.api.account;
+
+import org.jetbrains.annotations.NotNull;
+import pro.cloudnode.smp.bankaccounts.api.TypedIdentifier;
+
+/**
+ * Represents an account identifier.
+ */
+public final class AccountId extends TypedIdentifier {
+ /**
+ * Constructs an account identifier.
+ *
+ * @param id the account ID
+ */
+ public AccountId(@NotNull String id) {
+ super(Type.ACCOUNT, id);
+ }
+}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsRepository.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsRepository.java
index b4e4663..1e43dce 100644
--- a/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsRepository.java
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsRepository.java
@@ -85,7 +85,7 @@ INSERT INTO bank_accounts (
created
) VALUES (?, ?, ?, ?, ?, ?)
""", stmt -> {
- stmt.setString(1, account.id());
+ stmt.setString(1, account.id().id());
stmt.setInt(2, account.type().ordinal());
stmt.setBoolean(3, account.allowNegative());
stmt.setInt(4, account.status().ordinal());
@@ -109,7 +109,7 @@ public void update(final @NotNull Account account) throws RepositoryException {
stmt.setBoolean(1, account.allowNegative());
stmt.setInt(2, account.status().ordinal());
stmt.setString(3, account.name().orElse(null));
- stmt.setString(4, account.id());
+ stmt.setString(4, account.id().id());
}
) > 0;
@@ -123,7 +123,7 @@ public void update(final @NotNull Account account) throws RepositoryException {
@NotNull
protected Account map(final @NotNull ResultSet resultSet) throws SQLException {
return new Account(
- resultSet.getString("id"),
+ new AccountId(resultSet.getString("id")),
Account.Type.values()[resultSet.getInt("type")],
resultSet.getBoolean("allow_negative"),
Account.Status.values()[resultSet.getInt("status")],
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsService.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsService.java
index 5c8a3ff..47b3d12 100644
--- a/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsService.java
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/account/AccountsService.java
@@ -74,13 +74,13 @@ public AccountsService(
* @throws InternalException if the account cannot be persisted due to an internal error
*/
@NotNull
- public Account create(final @NotNull String id, final @NotNull Account.Type type) throws InternalException {
- if (id.length() > MAX_ID_LENGTH) {
+ public Account create(final @NotNull AccountId id, final @NotNull Account.Type type) throws InternalException {
+ if (id.id().length() > MAX_ID_LENGTH) {
throw new IllegalArgumentException(String.format("Account ID cannot exceed %d characters", MAX_ID_LENGTH));
}
try {
- if (repository.exists(id)) {
+ if (repository.exists(id.id())) {
throw new IllegalArgumentException(String.format("Account with ID %s already exists", id));
}
} catch (final Repository.RepositoryException e) {
@@ -113,7 +113,7 @@ public Account create(final @NotNull Account.Type type) throws IllegalStateExcep
String id = IdGenerator.BASE58.random(idLength);
try {
if (!repository.exists(id)) {
- return create(id, type);
+ return create(new AccountId(id), type);
}
} catch (final Repository.RepositoryException e) {
logger.log(
@@ -142,9 +142,9 @@ public Account create(final @NotNull Account.Type type) throws IllegalStateExcep
* @throws InternalException if the account cannot be retrieved due to an internal error
*/
@NotNull
- public Optional get(final @NotNull String id) throws InternalException {
+ public Optional get(final @NotNull AccountId id) throws InternalException {
try {
- return repository.getById(id);
+ return repository.getById(id.id());
} catch (final Repository.RepositoryException e) {
logger.log(Level.SEVERE, String.format("Failed to retrieve account with ID: %s", id), e);
throw new InternalException("Failed to retrieve account");
@@ -155,18 +155,18 @@ public Optional get(final @NotNull String id) throws InternalException
/**
* Retrieves the account with the specified ID if it is not frozen.
*
- * @param accountId the ID of the account to retrieve
+ * @param id the ID of the account to retrieve
* @return the account if found and not frozen
* @throws AccountNotFoundException if no account exists with the given ID
* @throws AccountFrozenException if the account is frozen
* @throws InternalException if the account cannot be retrieved due to an internal error
*/
@NotNull
- public Account getNonFrozenAccountOrThrow(String accountId)
+ public Account getNonFrozenAccountOrThrow(final @NotNull AccountId id)
throws AccountNotFoundException, AccountFrozenException, InternalException {
- final Account account = get(accountId).orElseThrow(() -> new AccountNotFoundException(accountId));
+ final Account account = get(id).orElseThrow(() -> new AccountNotFoundException(id));
if (account.frozen()) {
- throw new AccountFrozenException(accountId);
+ throw new AccountFrozenException(id);
}
return account;
}
@@ -181,7 +181,7 @@ public static final class AccountNotFoundException extends ServiceException {
* @param id the account ID
*/
@ApiStatus.Internal
- public AccountNotFoundException(final @NotNull String id) {
+ public AccountNotFoundException(final @NotNull AccountId id) {
super(String.format("Account %s does not exist", id));
}
}
@@ -196,7 +196,7 @@ public static final class AccountFrozenException extends ServiceException {
* @param id the account ID
*/
@ApiStatus.Internal
- public AccountFrozenException(final @NotNull String id) {
+ public AccountFrozenException(final @NotNull AccountId id) {
super(String.format("Account %s is frozen", id));
}
}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccessControlListEntry.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccessControlListEntry.java
new file mode 100644
index 0000000..8862422
--- /dev/null
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccessControlListEntry.java
@@ -0,0 +1,101 @@
+/*
+ * BankAccounts is a Minecraft economy plugin that enables players to hold multiple bank accounts.
+ * Copyright © 2023–2026 Cloudnode OÜ.
+ *
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with this program. If not, see
+ * .
+ */
+
+package pro.cloudnode.smp.bankaccounts.api.acl;
+
+import org.jetbrains.annotations.NotNull;
+import pro.cloudnode.smp.bankaccounts.Serializable;
+import pro.cloudnode.smp.bankaccounts.api.TypedIdentifier;
+
+import java.time.Instant;
+
+/**
+ * Represents an access control list entry.
+ *
+ * @param the relation type
+ */
+public abstract class AccessControlListEntry {
+ private final @NotNull TypedIdentifier subject;
+ private @NotNull T relation;
+ private final @NotNull TypedIdentifier resource;
+ private final @NotNull Instant created;
+
+ AccessControlListEntry(
+ final @NotNull TypedIdentifier subject,
+ final @NotNull T relation,
+ final @NotNull TypedIdentifier resource,
+ final @NotNull Instant created
+ ) {
+ this.subject = subject;
+ this.relation = relation;
+ this.resource = resource;
+ this.created = created;
+ }
+
+ /**
+ * Returns the identifier of the subject.
+ *
+ * @return the subject ID
+ */
+ @NotNull
+ public TypedIdentifier subject() {
+ return subject;
+ }
+
+ /**
+ * Returns the relation of the subject to the resource.
+ *
+ * @return the relation
+ */
+ @NotNull
+ public T relation() {
+ return relation;
+ }
+
+ /**
+ * Sets the relation of the subject to the resource.
+ *
+ * @param relation the new relation
+ */
+ public void relation(final @NotNull T relation) {
+ this.relation = relation;
+ }
+
+ /**
+ * Returns the identifier of the resource.
+ *
+ * @return the resource ID
+ */
+ @NotNull
+ public TypedIdentifier resource() {
+ return resource;
+ }
+
+ /**
+ * Returns the timestamp when the ACL entry was created.
+ *
+ * @return the creation timestamp
+ */
+ @NotNull
+ public Instant created() {
+ return created;
+ }
+
+ /**
+ * Represents the relation of a subject to a resource.
+ */
+ public interface Relation extends Serializable {
+ }
+}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccountAclEntry.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccountAclEntry.java
new file mode 100644
index 0000000..e1cb214
--- /dev/null
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccountAclEntry.java
@@ -0,0 +1,108 @@
+/*
+ * BankAccounts is a Minecraft economy plugin that enables players to hold multiple bank accounts.
+ * Copyright © 2023–2026 Cloudnode OÜ.
+ *
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with this program. If not, see
+ * .
+ */
+
+package pro.cloudnode.smp.bankaccounts.api.acl;
+
+import org.jetbrains.annotations.NotNull;
+import pro.cloudnode.smp.bankaccounts.api.TypedIdentifier;
+import pro.cloudnode.smp.bankaccounts.api.account.AccountId;
+
+import java.time.Instant;
+
+/**
+ * Represents an access control list entry for an account.
+ */
+public final class AccountAclEntry extends AccessControlListEntry {
+ AccountAclEntry(
+ final @NotNull TypedIdentifier subject,
+ final @NotNull AccountAclEntry.Role role,
+ final @NotNull AccountId account,
+ final @NotNull Instant created
+ ) {
+ super(subject, role, account, created);
+ }
+
+ /**
+ * Constructs a new ACL entry.
+ *
+ * @param subject the subject identifier
+ * @param role the role of the subject
+ * @param account the account ID
+ */
+ public AccountAclEntry(
+ final @NotNull TypedIdentifier subject,
+ final @NotNull Role role,
+ final @NotNull AccountId account
+ ) {
+ this(subject, role, account, Instant.now());
+ }
+
+ /**
+ * Returns the ID of the account to which this ACL entry applies.
+ *
+ * @return the account ID
+ */
+ @NotNull
+ public AccountId account() {
+ return (AccountId) resource();
+ }
+
+ /**
+ * Represents the relation of a subject to an account.
+ */
+ public enum Role implements AccessControlListEntry.Relation {
+ /**
+ * Read-only access to the account.
+ */
+ VIEWER,
+
+ /**
+ * Read-only access to most of the account, but can request payments.
+ */
+ COLLECTOR,
+
+ /**
+ * Access to initiate money transfers and request payments.
+ */
+ SIGNATORY,
+
+ /**
+ * Full access to the account, except ACL management or account closure.
+ */
+ MANAGER,
+
+ /**
+ * Full access to the account.
+ */
+ OWNER;
+
+ @Override
+ @NotNull
+ public final String serialize() {
+ return name().toLowerCase();
+ }
+
+ /**
+ * Returns a role from string.
+ *
+ * @param value the string representation of the role
+ * @return the role
+ */
+ @NotNull
+ public static Role deserialize(final @NotNull String value) {
+ return valueOf(value.toUpperCase());
+ }
+ }
+}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccountAclRepository.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccountAclRepository.java
new file mode 100644
index 0000000..ae2a5b5
--- /dev/null
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AccountAclRepository.java
@@ -0,0 +1,192 @@
+/*
+ * BankAccounts is a Minecraft economy plugin that enables players to hold multiple bank accounts.
+ * Copyright © 2023–2026 Cloudnode OÜ.
+ *
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with this program. If not, see
+ * .
+ */
+
+package pro.cloudnode.smp.bankaccounts.api.acl;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import pro.cloudnode.smp.bankaccounts.api.Repository;
+import pro.cloudnode.smp.bankaccounts.api.TypedIdentifier;
+import pro.cloudnode.smp.bankaccounts.api.account.AccountId;
+
+import javax.sql.DataSource;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Represents the account access control list repository.
+ */
+@ApiStatus.Internal
+public final class AccountAclRepository extends Repository {
+ /**
+ * Constructs an account ACL repository with the specified data source.
+ *
+ * @param dataSource the database source
+ */
+ @ApiStatus.Internal
+ public AccountAclRepository(final @NotNull DataSource dataSource) {
+ super(dataSource);
+ }
+
+ /**
+ * Retrieves the ACL entry for the specified subject and account.
+ *
+ * @param subject the subject identifier
+ * @param account the account ID
+ * @return the ACL entry if found, otherwise empty
+ * @throws RepositoryException if an error occurs during query execution
+ */
+ @ApiStatus.Internal
+ @NotNull
+ public Optional get(final @NotNull TypedIdentifier subject, final @NotNull String account) {
+ return queryOne(
+ "SELECT * FROM bank_account_acl_entries WHERE subject_type = ? AND subject_id = ? AND account = ?",
+ stmt -> {
+ stmt.setString(1, subject.type().name());
+ stmt.setString(2, subject.id());
+ stmt.setString(3, account);
+ }
+ );
+ }
+
+ /**
+ * Retrieves all ACL entries for a given account.
+ *
+ * @param account the account ID
+ * @return the list of ACL entries
+ * @throws RepositoryException if an error occurs during query execution
+ */
+ @ApiStatus.Internal
+ @NotNull
+ public List getAllForAccount(final @NotNull String account) {
+ return queryMany(
+ "SELECT * FROM bank_account_acl_entries WHERE account = ?",
+ stmt -> stmt.setString(1, account)
+ );
+ }
+
+ /**
+ * Retrieves all ACL entries for a given subject.
+ *
+ * @param subject the subject identifier
+ * @return the list of ACL entries
+ * @throws RepositoryException if an error occurs during query execution
+ */
+ @ApiStatus.Internal
+ @NotNull
+ public List getAllForSubject(final @NotNull TypedIdentifier subject) {
+ return queryMany(
+ "SELECT * FROM bank_account_acl_entries WHERE subject_type = ? AND subject_id = ?", stmt -> {
+ stmt.setString(1, subject.type().name());
+ stmt.setString(2, subject.id());
+ }
+ );
+ }
+
+ /**
+ * Inserts a new ACL entry into the database.
+ *
+ * @param entry the ACL entry to insert
+ * @throws RepositoryException if an error occurs during query execution
+ */
+ @ApiStatus.Internal
+ public void insert(final @NotNull AccountAclEntry entry) {
+ queryUpdate(
+ """
+ INSERT INTO bank_account_acl_entries (
+ subject_type,
+ subject_id,
+ relation,
+ account,
+ created
+ ) VALUES (?, ?, ?, ?, ?)
+ """, stmt -> {
+ stmt.setString(1, entry.subject().type().name());
+ stmt.setString(2, entry.subject().id());
+ stmt.setString(3, entry.relation().serialize());
+ stmt.setString(4, entry.account().id());
+ stmt.setTimestamp(5, Timestamp.from(entry.created()));
+ }
+ );
+ }
+
+ /**
+ * Updates an existing ACL entry in the database.
+ *
+ * @param entry the ACL entry to update
+ * @throws IllegalStateException if no rows were modified
+ * @throws Repository.RepositoryException if an error occurs during query execution
+ */
+ @ApiStatus.Internal
+ public void update(final @NotNull AccountAclEntry entry) {
+ final boolean updated = queryUpdate(
+ """
+ UPDATE bank_account_acl_entries
+ SET relation = ?
+ WHERE subject_type = ? AND subject_id = ? AND account = ?
+ """, stmt -> {
+ stmt.setString(1, entry.relation().serialize());
+ stmt.setString(2, entry.subject().type().name());
+ stmt.setString(3, entry.subject().id());
+ stmt.setString(4, entry.account().id());
+ }
+ ) > 0;
+
+ if (!updated) {
+ throw new IllegalStateException(String.format(
+ "No rows were modified for subject %s on account %s",
+ entry.subject().id(),
+ entry.account()
+ ));
+ }
+ }
+
+ /**
+ * Removes an ACL entry from the database.
+ *
+ * @param subject the subject identifier
+ * @param accountId the account ID
+ * @throws RepositoryException if an error occurs during query execution
+ */
+ @ApiStatus.Internal
+ public void remove(final @NotNull TypedIdentifier subject, final @NotNull String accountId) {
+ queryUpdate(
+ "DELETE FROM bank_account_acl_entries WHERE subject_type = ? AND subject_id = ? AND account = ?",
+ stmt -> {
+ stmt.setString(1, subject.type().name());
+ stmt.setString(2, subject.id());
+ stmt.setString(3, accountId);
+ }
+ );
+ }
+
+ @ApiStatus.Internal
+ @Override
+ @NotNull
+ protected AccountAclEntry map(final @NotNull ResultSet resultSet) throws SQLException {
+ return new AccountAclEntry(
+ new TypedIdentifier(
+ TypedIdentifier.Type.valueOf(resultSet.getString("subject_type")),
+ resultSet.getString("subject_id")
+ ),
+ AccountAclEntry.Role.valueOf(resultSet.getString("relation").toUpperCase()),
+ new AccountId(resultSet.getString("account")),
+ resultSet.getTimestamp("created").toInstant()
+ );
+ }
+}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AclService.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AclService.java
new file mode 100644
index 0000000..18dbfe3
--- /dev/null
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/acl/AclService.java
@@ -0,0 +1,93 @@
+/*
+ * BankAccounts is a Minecraft economy plugin that enables players to hold multiple bank accounts.
+ * Copyright © 2023–2026 Cloudnode OÜ.
+ *
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with this program. If not, see
+ * .
+ */
+
+package pro.cloudnode.smp.bankaccounts.api.acl;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import pro.cloudnode.smp.bankaccounts.api.Repository;
+import pro.cloudnode.smp.bankaccounts.api.Service;
+import pro.cloudnode.smp.bankaccounts.api.TypedIdentifier;
+import pro.cloudnode.smp.bankaccounts.api.account.AccountId;
+
+import javax.sql.DataSource;
+import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Represents the ACL service.
+ */
+public final class AclService extends Service {
+ private final @NotNull AccountAclRepository account;
+
+ /**
+ * Constructs a new ACL service.
+ *
+ * @param parentLogger the parent logger, or null if none
+ * @param dataSource the database source
+ */
+ @ApiStatus.Internal
+ public AclService(final @Nullable Logger parentLogger, final @NotNull DataSource dataSource) {
+ super(parentLogger);
+ this.account = new AccountAclRepository(dataSource);
+ }
+
+ /**
+ * Retrieves the ACL account entry for the specified subject and account.
+ *
+ * @param subject the subject identifier
+ * @param account the account identifier
+ * @return the ACL entry if present, otherwise empty
+ * @throws ServiceException if the subject type is not allowed for accounts
+ */
+ @NotNull
+ public Optional get(final @NotNull TypedIdentifier subject, final @NotNull AccountId account)
+ throws ServiceException {
+ if (subject.type() != TypedIdentifier.Type.HOLDER) {
+ throw new InternalException(String.format("Invalid subject type %s for accounts", subject.type().name()));
+ }
+ try {
+ return this.account.get(subject, account.id());
+ } catch (final Repository.RepositoryException e) {
+ logger.log(
+ Level.SEVERE,
+ String.format("Failed to get ACL for: %s, %s", subject.serialize(), account.serialize()),
+ e
+ );
+ throw new InternalException("Failed to check for ID collision");
+ }
+ }
+
+ /**
+ * Retrieves the ACL entry for the specified subject and resource.
+ *
+ * @param subject the subject identifier
+ * @param resource the resource identifier
+ * @return the ACL entry if present, otherwise empty
+ * @throws ServiceException if the subject type is invalid for the resource or the resource type is unsupported
+ */
+ @NotNull
+ public Optional extends AccessControlListEntry>> get(
+ final @NotNull TypedIdentifier subject,
+ final @NotNull TypedIdentifier resource
+ ) throws ServiceException {
+ return switch (resource.type()) {
+ case ACCOUNT -> get(subject, new AccountId(resource.id()));
+ default -> throw new InternalException(String.format("%s is not a resource ID", resource.serialize()));
+ };
+ }
+}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerEntry.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerEntry.java
index 5e22445..b422a64 100644
--- a/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerEntry.java
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerEntry.java
@@ -17,6 +17,7 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import pro.cloudnode.smp.bankaccounts.api.account.AccountId;
import java.math.BigDecimal;
import java.time.Instant;
@@ -27,26 +28,26 @@
*/
public final class LedgerEntry {
private final @NotNull String id;
- private final @NotNull String account;
+ private final @NotNull AccountId account;
private final @NotNull BigDecimal amount;
private final @NotNull BigDecimal balance;
private final @NotNull Instant created;
private final @NotNull LedgerEntry.Initiator initiator;
private final @NotNull String channel;
private final @Nullable String description;
- private final @Nullable String relatedAccount;
+ private final @Nullable AccountId relatedAccount;
private final @Nullable String previousId;
LedgerEntry(
final @NotNull String id,
- final @NotNull String account,
+ final @NotNull AccountId account,
final @NotNull BigDecimal amount,
final @NotNull BigDecimal balance,
final @NotNull Instant created,
final @NotNull LedgerEntry.Initiator initiator,
final @NotNull String channel,
final @Nullable String description,
- final @Nullable String relatedAccount,
+ final @Nullable AccountId relatedAccount,
final @Nullable String previousId
) {
this.id = id;
@@ -77,7 +78,7 @@ public String id() {
* @return the account ID
*/
@NotNull
- public String account() {
+ public AccountId account() {
return account;
}
@@ -149,7 +150,7 @@ public Optional description() {
* @return the related account ID if the account exists, empty otherwise
*/
@NotNull
- public Optional relatedAccount() {
+ public Optional relatedAccount() {
return Optional.ofNullable(relatedAccount);
}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerRepository.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerRepository.java
index 45c65ea..c4d4f81 100644
--- a/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerRepository.java
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerRepository.java
@@ -18,6 +18,8 @@
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import pro.cloudnode.smp.bankaccounts.api.Repository;
+import pro.cloudnode.smp.bankaccounts.api.TypedIdentifier;
+import pro.cloudnode.smp.bankaccounts.api.account.AccountId;
import javax.sql.DataSource;
import java.sql.ResultSet;
@@ -147,14 +149,14 @@ INSERT INTO bank_ledger_entries (
""",
Stream.of(transaction.debit(), transaction.credit()).map((ledgerEntry) -> (stmt) -> {
stmt.setString(1, ledgerEntry.id());
- stmt.setString(2, ledgerEntry.account());
+ stmt.setString(2, ledgerEntry.account().id());
stmt.setBigDecimal(3, ledgerEntry.amount());
stmt.setBigDecimal(4, ledgerEntry.balance());
stmt.setTimestamp(5, Timestamp.from(ledgerEntry.created()));
stmt.setInt(6, ledgerEntry.initiator().ordinal());
stmt.setString(7, ledgerEntry.channel());
stmt.setString(8, ledgerEntry.description().orElse(null));
- stmt.setString(9, ledgerEntry.relatedAccount().orElse(null));
+ stmt.setString(9, ledgerEntry.relatedAccount().map(TypedIdentifier::id).orElse(null));
stmt.setString(10, ledgerEntry.previousId().orElse(null));
}).toList()
);
@@ -172,14 +174,14 @@ INSERT INTO bank_ledger_entries (
protected LedgerEntry map(final @NotNull ResultSet resultSet) throws SQLException {
return new LedgerEntry(
resultSet.getString("id"),
- resultSet.getString("account"),
+ new AccountId(resultSet.getString("account")),
resultSet.getBigDecimal("amount"),
resultSet.getBigDecimal("balance"),
resultSet.getTimestamp("created").toInstant(),
LedgerEntry.Initiator.values()[resultSet.getInt("initiator")],
resultSet.getString("channel"),
resultSet.getString("description"),
- resultSet.getString("related_account"),
+ new AccountId(resultSet.getString("related_account")),
resultSet.getString("previous_id")
);
}
diff --git a/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerService.java b/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerService.java
index 603175f..7ae2b80 100644
--- a/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerService.java
+++ b/src/main/java/pro/cloudnode/smp/bankaccounts/api/ledger/LedgerService.java
@@ -22,6 +22,7 @@
import pro.cloudnode.smp.bankaccounts.api.Repository;
import pro.cloudnode.smp.bankaccounts.api.Service;
import pro.cloudnode.smp.bankaccounts.api.account.Account;
+import pro.cloudnode.smp.bankaccounts.api.account.AccountId;
import pro.cloudnode.smp.bankaccounts.api.account.AccountsService;
import javax.sql.DataSource;
@@ -79,10 +80,10 @@ public LedgerService(
* @throws InternalException if the ledger could not be queried due to an internal error
*/
@NotNull
- public Optional getLast(final @NotNull String account) throws InternalException {
+ public Optional getLast(final @NotNull AccountId account) throws InternalException {
final List latest;
try {
- latest = repository.getLatest(account, 1);
+ latest = repository.getLatest(account.id(), 1);
} catch (final Repository.RepositoryException e) {
logger.log(Level.SEVERE, String.format("Failed to get last ledger entry for account %s", account), e);
throw new InternalException("Failed to get ledger entry");
@@ -103,10 +104,10 @@ public Optional getLast(final @NotNull String account) throws Inter
* entries
* @throws InternalException if the balance could not be queried due to an internal error
* @see LedgerEntry#balance()
- * @see #getLast(String)
+ * @see #getLast(AccountId)
*/
@NotNull
- public BigDecimal getBalance(final @NotNull String account) throws InternalException {
+ public BigDecimal getBalance(final @NotNull AccountId account) throws InternalException {
return getLast(account).map(LedgerEntry::balance).orElse(BigDecimal.ZERO);
}
@@ -129,8 +130,8 @@ public BigDecimal getBalance(final @NotNull String account) throws InternalExcep
*/
@NotNull
public Transaction createTransfer(
- final @NotNull String sender,
- final @NotNull String recipient,
+ final @NotNull AccountId sender,
+ final @NotNull AccountId recipient,
final @NotNull BigDecimal amount,
final @NotNull LedgerEntry.Initiator initiator,
final @NotNull String channel,
@@ -285,7 +286,7 @@ public static final class InsufficientBalanceException extends ServiceException
/**
* ID of the account which has insufficient funds.
*/
- public final @NotNull String account;
+ public final @NotNull AccountId account;
/**
* Balance of the account.
@@ -298,7 +299,7 @@ public static final class InsufficientBalanceException extends ServiceException
public final @NotNull BigDecimal amount;
private InsufficientBalanceException(
- final @NotNull String account,
+ final @NotNull AccountId account,
final @NotNull BigDecimal balance,
final @NotNull BigDecimal amount
) {
diff --git a/src/main/resources/db/init.sql b/src/main/resources/db/init.sql
index a2e28a7..861b5bf 100644
--- a/src/main/resources/db/init.sql
+++ b/src/main/resources/db/init.sql
@@ -38,3 +38,16 @@ CREATE TABLE bank_ledger_entries
);
CREATE INDEX idx_bank_ledger_entries_account_created_desc ON bank_ledger_entries (account, created DESC);
+
+CREATE TABLE bank_account_acl_entries
+(
+ subject_type VARCHAR(64) NOT NULL,
+ subject_id VARCHAR(64) NOT NULL,
+ relation VARCHAR(64) NOT NULL,
+ account VARCHAR(32) NOT NULL,
+ created NOT NULL,
+ PRIMARY KEY (subject_type, subject_id, account),
+ FOREIGN KEY (account) REFERENCES bank_accounts (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_bank_account_acl_entries_subject ON bank_account_acl_entries (subject_type, subject_id);