A Move package for managing recruiter access control and candidate view requests on Sui blockchain. This package provides a secure way to manage who can access candidate information through an on-chain access policy system.
This package consists of two main modules:
- access_policy - Manages recruiter permissions and access control
- view_request - Handles candidate profile view requests with approval workflow
Testnet Package ID: 0xb35fbef347e1a4ea13adb7bd0f24f6c9e82117f5715da28dbf8924539bd2178a
The access_policy module provides a capability-based access control system for managing recruiter permissions.
A shared object that maintains a list of allowed recruiter addresses.
public struct AccessPolicy has key {
id: UID,
allowed: vector<address>,
}A capability object that proves ownership and allows policy modifications.
public struct AdminCap has key, store {
id: UID,
policy_id: ID,
}Creates a new access policy with initial recruiters.
public entry fun create(
initial_recruiter: address,
ctx: &mut TxContext
)- Creates an
AccessPolicyobject (shared) - this will be used to verify recruiter access - Creates an
AdminCapfor the creator - this capability is required to add new recruiters - Automatically adds the creator (typically the recruitment platform's wallet address) and
initial_recruiterto the allowed list
Returns:
access_policy_id: The object ID of the created AccessPolicy (shared object)admin_cap_id: The object ID of the AdminCap (owned by the creator)
Adds a new recruiter to the allowed list.
public entry fun add_recruiter(
policy: &mut AccessPolicy,
cap: &AdminCap,
recruiter: address,
_ctx: &mut TxContext
)Required Parameters:
policy: Theaccess_policy_idobtained from thecreatefunctioncap: Theadmin_cap_idobtained from thecreatefunction (must be owned by the transaction sender)recruiter: The address of the new recruiter to be added
Function Behavior:
- Requires valid
AdminCapownership - Validates the capability matches the policy
- Adds the recruiter address to the allowed list
Validates that the sender is an approved recruiter.
public fun seal_approve(
policy: &AccessPolicy,
ctx: &TxContext
)- Checks if the transaction sender is in the allowed list
- Aborts with
E_NOT_ALLOWEDif unauthorized
The view_request module manages candidate profile view requests with an approval workflow.
Represents a request to view a candidate's profile.
public struct ViewRequest has key {
id: UID,
recruiter: address,
candidate_id: vector<u8>,
status: u8, // 0=Pending, 1=Approved, 2=Rejected
}Creates a new view request.
public entry fun create(
recruiter: address,
candidate_id: vector<u8>,
ctx: &mut TxContext
)- Creates a view request object
- Initial status is
0(Pending) - Transfers ownership to the transaction sender
Approves a view request.
public entry fun approve(
req: &mut ViewRequest,
_ctx: &mut TxContext
)- Sets request status to
1(Approved)
Rejects a view request.
public entry fun reject(
req: &mut ViewRequest,
_ctx: &mut TxContext
)- Sets request status to
2(Rejected)
- Sui CLI >= 1.26.0
- A funded Sui testnet wallet
sui move buildThis will compile the Move modules and generate build artifacts in the build/ directory.
sui move testsui client publish --gas-budget 200000000After successful deployment, save the package ID from the output. The current deployed package ID is:
0xb35fbef347e1a4ea13adb7bd0f24f6c9e82117f5715da28dbf8924539bd2178a
npm install @mysten/sui.jsimport { SuiClient } from '@mysten/sui.js/client';
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { Ed25519Keypair } from '@mysten/sui.js/keypairs/ed25519';
const PACKAGE_ID = '0xb35fbef347e1a4ea13adb7bd0f24f6c9e82117f5715da28dbf8924539bd2178a';
const MODULE_NAME = 'access_policy';
// Initialize client
const client = new SuiClient({ url: 'https://fullnode.testnet.sui.io:443' });
const keypair = Ed25519Keypair.deriveKeypair('your-mnemonics-here');
// 1. Create Access Policy
// This function should be called by the recruitment platform's wallet
// It creates an AccessPolicy and AdminCap, adding the platform wallet to the allowed list
async function createAccessPolicy(initialRecruiter: string) {
const tx = new TransactionBlock();
tx.moveCall({
target: `${PACKAGE_ID}::${MODULE_NAME}::create`,
arguments: [
tx.pure(initialRecruiter, 'address')
],
});
const result = await client.signAndExecuteTransactionBlock({
signer: keypair,
transactionBlock: tx,
options: {
showEffects: true,
showObjectChanges: true,
},
});
console.log('Policy created:', result);
// Extract the created object IDs from the result
const createdObjects = result.objectChanges?.filter(
(obj: any) => obj.type === 'created'
);
const accessPolicy = createdObjects?.find(
(obj: any) => obj.objectType.includes('AccessPolicy')
);
const adminCap = createdObjects?.find(
(obj: any) => obj.objectType.includes('AdminCap')
);
console.log('access_policy_id:', accessPolicy?.objectId);
console.log('admin_cap_id:', adminCap?.objectId);
return {
result,
access_policy_id: accessPolicy?.objectId,
admin_cap_id: adminCap?.objectId,
};
}
// 2. Add Recruiter
// Requires access_policy_id and admin_cap_id from the create function
// Only the AdminCap owner can add new recruiters
async function addRecruiter(
policyId: string, // access_policy_id from createAccessPolicy
adminCapId: string, // admin_cap_id from createAccessPolicy
recruiterAddress: string
) {
const tx = new TransactionBlock();
tx.moveCall({
target: `${PACKAGE_ID}::${MODULE_NAME}::add_recruiter`,
arguments: [
tx.object(policyId), // AccessPolicy object ID
tx.object(adminCapId), // AdminCap object ID (must be owned by signer)
tx.pure(recruiterAddress, 'address')
],
});
const result = await client.signAndExecuteTransactionBlock({
signer: keypair,
transactionBlock: tx,
});
console.log('Recruiter added:', result);
return result;
}
// 3. Create View Request
async function createViewRequest(
recruiterAddress: string,
candidateId: string
) {
const tx = new TransactionBlock();
// Convert candidateId to vector<u8>
const candidateIdBytes = Array.from(
new TextEncoder().encode(candidateId)
);
tx.moveCall({
target: `${PACKAGE_ID}::view_request::create`,
arguments: [
tx.pure(recruiterAddress, 'address'),
tx.pure(candidateIdBytes, 'vector<u8>')
],
});
const result = await client.signAndExecuteTransactionBlock({
signer: keypair,
transactionBlock: tx,
});
console.log('View request created:', result);
return result;
}
// 4. Approve View Request
async function approveViewRequest(requestId: string) {
const tx = new TransactionBlock();
tx.moveCall({
target: `${PACKAGE_ID}::view_request::approve`,
arguments: [
tx.object(requestId)
],
});
const result = await client.signAndExecuteTransactionBlock({
signer: keypair,
transactionBlock: tx,
});
console.log('Request approved:', result);
return result;
}
// 5. Verify Recruiter Access (dry-run)
async function verifyRecruiterAccess(policyId: string) {
const tx = new TransactionBlock();
// seal_approve is not an entry function, so we can't call it directly
// Instead, use it with dry-run to verify access without executing
tx.moveCall({
target: `${PACKAGE_ID}::${MODULE_NAME}::seal_approve`,
arguments: [
tx.object(policyId)
],
});
try {
// Use devInspectTransactionBlock to simulate without executing
const result = await client.devInspectTransactionBlock({
sender: await keypair.getPublicKey().toSuiAddress(),
transactionBlock: tx,
});
if (result.effects.status.status === 'success') {
console.log('Access verified: Sender is an authorized recruiter');
return true;
} else {
console.log('Access denied:', result.effects.status.error);
return false;
}
} catch (error) {
console.error('Verification failed:', error);
return false;
}
}
// 6. Query Access Policy
async function getAccessPolicy(policyId: string) {
const policy = await client.getObject({
id: policyId,
options: {
showContent: true,
},
});
console.log('Access Policy:', policy);
return policy;
}E_NOT_ALLOWED (1): The sender is not in the allowed recruiter listE_INVALID_CAP (2): The AdminCap does not match the AccessPolicy
.
├── Move.toml # Package manifest
├── sources/
│ ├── access_policy.move # Access control module
│ └── view_request.move # View request module
└── build/ # Compiled artifacts
- The
AccessPolicyis a shared object, allowing multiple parties to read it - Only the
AdminCapholder can add new recruiters - The
seal_approvefunction should be called before granting access to sensitive operations - View requests are owned objects, ensuring only the owner can approve/reject them
# Build
sui move build
# Test
sui move test
# Publish to local network
sui client publish --gas-budget 200000000