mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf06722b7 | |||
| 925c95ce88 | |||
| 3098b3b14b | |||
| dd1cd77ca0 | |||
| d20dbfd6a2 | |||
| 41145961f9 | |||
| 1f2119e273 | |||
| 6e97aff7ba | |||
| 276b0db625 | |||
| bac346f304 | |||
| 9f33d37add | |||
| 3e42bbf4fa | |||
| b5e5f0708a | |||
| f96bf3dd24 | |||
| c53457691d |
@@ -300,6 +300,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"description": "Configuration for permission module",
|
||||
"properties": {
|
||||
"readModel": {
|
||||
"type": "string",
|
||||
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
|
||||
"default": "projection"
|
||||
},
|
||||
"fallbackLegacyLoader": {
|
||||
"type": "boolean",
|
||||
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"storages": {
|
||||
"type": "object",
|
||||
"description": "Configuration for storages module",
|
||||
|
||||
Generated
+2
-2
@@ -3748,9 +3748,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "llm_adapter"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca30267ba36e247d1ff7a916a03db2ceb1de7f0bfcab7250cde006cdda68c19"
|
||||
checksum = "332397a6ccde5ac47fc32b29a2eed447135eb4ff6fd05ffb88dfe937ea9c8211"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"jsonschema",
|
||||
|
||||
+3
-3
@@ -16,10 +16,10 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
affine_common = { path = "./packages/common/native" }
|
||||
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
|
||||
ahash = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1"
|
||||
arbitrary = { version = "1.3", features = ["derive"] }
|
||||
assert-json-diff = "2.0"
|
||||
@@ -40,6 +40,7 @@ resolver = "3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
hex = "0.4"
|
||||
homedir = "0.3"
|
||||
image = { version = "0.25.9", default-features = false, features = [
|
||||
"bmp",
|
||||
@@ -81,6 +82,7 @@ resolver = "3"
|
||||
ogg = "0.9"
|
||||
once_cell = "1"
|
||||
ordered-float = "5"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
parking_lot = "0.12"
|
||||
path-ext = "0.1.2"
|
||||
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
|
||||
@@ -99,8 +101,6 @@ resolver = "3"
|
||||
screencapturekit = "0.3"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
sha2 = "0.10"
|
||||
sha3 = "0.10"
|
||||
smol_str = "0.3"
|
||||
|
||||
+2
-2
@@ -74,7 +74,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-lit": "^2.2.1",
|
||||
"eslint-plugin-oxlint": "1.64.0",
|
||||
"eslint-plugin-oxlint": "1.66.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
@@ -84,7 +84,7 @@
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.13.2",
|
||||
"oxlint": "1.58.0",
|
||||
"oxlint-tsgolint": "^0.19.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"prettier": "^3.7.4",
|
||||
"semver": "^7.7.3",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -706,8 +706,8 @@
|
||||
"optionalModels": [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
"claude-sonnet-4-5@20250929"
|
||||
"gemini-3.5-flash",
|
||||
"claude-sonnet-4-6"
|
||||
],
|
||||
"config": {
|
||||
"tools": [
|
||||
@@ -722,11 +722,7 @@
|
||||
"codeArtifact",
|
||||
"blobRead"
|
||||
],
|
||||
"proModels": [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
"claude-sonnet-4-5@20250929"
|
||||
]
|
||||
"proModels": ["gemini-2.5-pro", "gemini-3.5-flash", "claude-sonnet-4-6"]
|
||||
},
|
||||
"builtins": [
|
||||
"date",
|
||||
|
||||
@@ -61,12 +61,12 @@ mod tests {
|
||||
fn should_resolve_backend_scoped_alias() {
|
||||
let response = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
|
||||
backend_kind: Some("anthropic_vertex".to_string()),
|
||||
model_id: "claude-sonnet-4.5".to_string(),
|
||||
model_id: "claude-sonnet-4.6".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.matched_by.as_deref(), Some("canonical"));
|
||||
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-5@20250929");
|
||||
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,10 @@ fn restricted_decision(input: &PermissionEvaluationInputV1, action: &str) -> Vec
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut restrictions = Vec::new();
|
||||
if !input.runtime.known {
|
||||
restrictions.push(PermissionDecisionRestrictionV1 {
|
||||
|
||||
@@ -347,9 +347,12 @@ mod tests {
|
||||
local: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.runtime.known = false;
|
||||
input.runtime.stale = true;
|
||||
input.workspace_actions = vec!["Workspace.Delete".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(decision(&output.workspace.decisions, "Workspace.Delete").allowed);
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,6 +5,7 @@ import ava, { type ExecutionContext, type TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Cache, CryptoHelper } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { Models, WorkspaceRole } from '../../models';
|
||||
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
|
||||
import { ByokService } from '../../plugins/copilot/byok';
|
||||
@@ -14,6 +15,11 @@ import {
|
||||
ByokKeyTestStatus,
|
||||
ByokProvider,
|
||||
} from '../../plugins/copilot/byok/types';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../plugins/payment/types';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
|
||||
interface Context {
|
||||
@@ -24,11 +30,18 @@ interface Context {
|
||||
byok: ByokService;
|
||||
crypto: CryptoHelper;
|
||||
cache: Cache;
|
||||
entitlement: EntitlementService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
const test = ava.serial as TestFn<Context>;
|
||||
const originalNamespace = globalThis.env.NAMESPACE;
|
||||
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
|
||||
test.before(async t => {
|
||||
Object.assign(globalThis.env, {
|
||||
NAMESPACE: 'dev',
|
||||
DEPLOYMENT_TYPE: 'affine',
|
||||
});
|
||||
const module = await createTestingModule();
|
||||
t.context.module = module;
|
||||
t.context.models = module.get(Models);
|
||||
@@ -37,6 +50,7 @@ test.before(async t => {
|
||||
t.context.byok = module.get(ByokService);
|
||||
t.context.crypto = module.get(CryptoHelper);
|
||||
t.context.cache = module.get(Cache);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
@@ -45,6 +59,10 @@ test.beforeEach(async t => {
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
Object.assign(globalThis.env, {
|
||||
NAMESPACE: originalNamespace,
|
||||
DEPLOYMENT_TYPE: originalDeploymentType,
|
||||
});
|
||||
});
|
||||
|
||||
async function createUserWorkspace(t: ExecutionContext<Context>) {
|
||||
@@ -59,6 +77,73 @@ function workspaceHash(workspaceId: string) {
|
||||
return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
async function grantUserPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
userId: string,
|
||||
feature: ByokUserPlanFeature = 'pro_plan_v1'
|
||||
) {
|
||||
if (feature === 'unlimited_copilot') {
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring:
|
||||
feature === 'lifetime_pro_plan_v1'
|
||||
? SubscriptionRecurring.Lifetime
|
||||
: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeUserPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
userId: string,
|
||||
feature: ByokUserPlanFeature = 'pro_plan_v1'
|
||||
) {
|
||||
if (feature === 'unlimited_copilot') {
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
});
|
||||
}
|
||||
|
||||
async function grantTeamPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
workspaceId: string
|
||||
) {
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeTeamPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
workspaceId: string
|
||||
) {
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
});
|
||||
}
|
||||
|
||||
type ByokMatrixCase = {
|
||||
name: string;
|
||||
role: WorkspaceRole;
|
||||
@@ -110,25 +195,13 @@ async function createByokMatrixWorkspace(
|
||||
);
|
||||
}
|
||||
if (input.team) {
|
||||
await t.context.models.workspaceFeature.add(
|
||||
workspace.id,
|
||||
'team_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantTeamPlan(t, workspace.id);
|
||||
}
|
||||
if (input.ownerPlan) {
|
||||
await t.context.models.userFeature.add(
|
||||
owner.id,
|
||||
input.ownerPlanFeature ?? 'pro_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantUserPlan(t, owner.id, input.ownerPlanFeature);
|
||||
}
|
||||
if (input.actorPlan && actor.id !== owner.id) {
|
||||
await t.context.models.userFeature.add(
|
||||
actor.id,
|
||||
input.actorPlanFeature ?? 'pro_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantUserPlan(t, actor.id, input.actorPlanFeature);
|
||||
}
|
||||
|
||||
return { owner, actor, workspace };
|
||||
@@ -252,7 +325,7 @@ for (const matrixCase of byokManagementMatrix) {
|
||||
|
||||
test('byok service persists encrypted server keys and never returns plaintext', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const primary = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -325,7 +398,7 @@ test('byok service persists encrypted server keys and never returns plaintext',
|
||||
|
||||
test('byok service preserves server key fields during partial updates', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -381,7 +454,7 @@ test('byok service preserves server key fields during partial updates', async t
|
||||
|
||||
test('local leases are short lived and do not persist keys to server configs', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const before = Date.now();
|
||||
const lease = await t.context.byok.createLocalLease({
|
||||
@@ -486,7 +559,7 @@ test('local leases persist normalized custom endpoints', async t => {
|
||||
).get(() => true);
|
||||
t.teardown(() => customEndpointSupported.restore());
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const lease = await t.context.byok.createLocalLease({
|
||||
workspaceId: workspace.id,
|
||||
@@ -659,13 +732,10 @@ for (const matrixCase of byokProfileAvailabilityMatrix) {
|
||||
}
|
||||
|
||||
if (matrixCase.revokeOwnerPlan) {
|
||||
await t.context.models.userFeature.remove(owner.id, 'pro_plan_v1');
|
||||
await revokeUserPlan(t, owner.id);
|
||||
}
|
||||
if (matrixCase.revokeTeam) {
|
||||
await t.context.models.workspaceFeature.remove(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
);
|
||||
await revokeTeamPlan(t, workspace.id);
|
||||
}
|
||||
if (matrixCase.demoteActor) {
|
||||
await t.context.models.workspaceUser.set(
|
||||
@@ -695,7 +765,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const profiles = await t.context.byok.getProfiles({
|
||||
workspaceId: randomUUID(),
|
||||
@@ -707,7 +777,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
|
||||
|
||||
test('test key failure disables a saved key and success restores it', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -778,7 +848,7 @@ test('test key failure disables a saved key and success restores it', async t =>
|
||||
|
||||
test('local key test does not mutate saved server config', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -817,7 +887,7 @@ test('local key test does not mutate saved server config', async t => {
|
||||
|
||||
test('Gemini key test sends key in header and returns safe failure message', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
new Response(
|
||||
@@ -852,7 +922,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
|
||||
|
||||
test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
new Response('{}', { status: 200 })
|
||||
@@ -877,7 +947,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
|
||||
test('provider test failures do not return raw provider response body', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const cases = [
|
||||
{
|
||||
body: 'authorization: Bearer token=a+b%2F==',
|
||||
@@ -925,7 +995,7 @@ test('provider test failures do not return raw provider response body', async t
|
||||
|
||||
test('dispatch failure disables server BYOK key by provider id', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -956,7 +1026,7 @@ test('dispatch failure disables server BYOK key by provider id', async t => {
|
||||
|
||||
test('dispatch accounting ignores provider ids from another workspace hash', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const otherWorkspace = await t.context.models.workspace.create(user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -996,7 +1066,7 @@ test('dispatch accounting ignores provider ids from another workspace hash', asy
|
||||
|
||||
test('effective profiles use local lease before server keys and skip disabled keys', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const serverKey = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -1067,7 +1137,7 @@ test('effective profiles use local lease before server keys and skip disabled ke
|
||||
|
||||
test('capability warnings match server Gemini background coverage', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const emptySettings = await t.context.byok.getSettings(workspace.id, user.id);
|
||||
t.deepEqual(
|
||||
|
||||
@@ -732,7 +732,7 @@ test('should be able to chat with special image model', async t => {
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(app, sessionId, 'some-tag', [
|
||||
`https://example.com/${promptName}.jpg`,
|
||||
smallestPng,
|
||||
]);
|
||||
const ret3 = await chatWithImages(app, sessionId, messageId);
|
||||
t.is(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { AuthService } from '../../core/auth';
|
||||
import { QuotaModule } from '../../core/quota';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
|
||||
import {
|
||||
ContextCategories,
|
||||
@@ -101,6 +102,7 @@ type Context = {
|
||||
actionBridge: ActionRuntimeBridge;
|
||||
cronJobs: CopilotCronJobs;
|
||||
subscription: SubscriptionService;
|
||||
quotaState: QuotaStateService;
|
||||
};
|
||||
|
||||
const buildTurn = (
|
||||
@@ -199,6 +201,7 @@ test.before(async t => {
|
||||
const workspaceEmbedding = module.get(CopilotWorkspaceService);
|
||||
const cronJobs = module.get(CopilotCronJobs);
|
||||
const subscription = module.get(SubscriptionService);
|
||||
const quotaState = module.get(QuotaStateService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
@@ -225,6 +228,7 @@ test.before(async t => {
|
||||
t.context.workspaceEmbedding = workspaceEmbedding;
|
||||
t.context.cronJobs = cronJobs;
|
||||
t.context.subscription = subscription;
|
||||
t.context.quotaState = quotaState;
|
||||
|
||||
await module.initTestingDB();
|
||||
});
|
||||
@@ -2172,7 +2176,7 @@ test('model selection policy should resolve requested optional models consistent
|
||||
});
|
||||
|
||||
test('capability policy host should gate pro model requests by subscription status', async t => {
|
||||
const { subscription, module } = t.context;
|
||||
const { quotaState, subscription, module } = t.context;
|
||||
const capabilityPolicy = module.get(CapabilityPolicyHost);
|
||||
|
||||
const mockStatus = (status?: SubscriptionStatus) => {
|
||||
@@ -2181,6 +2185,10 @@ test('capability policy host should gate pro model requests by subscription stat
|
||||
// @ts-expect-error mock
|
||||
getSubscription: async () => (status ? { status } : null),
|
||||
}));
|
||||
Sinon.stub(quotaState, 'reconcileUserQuotaState').resolves({
|
||||
plan: status === SubscriptionStatus.Active ? 'pro' : 'free',
|
||||
flags: {},
|
||||
} as Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>);
|
||||
};
|
||||
|
||||
// payment disabled -> allow requested if in optional; pro not blocked
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { DocReader } from '../../core/doc';
|
||||
import type { AccessController } from '../../core/permission';
|
||||
import type { PermissionAccess } from '../../core/permission';
|
||||
import type { Models } from '../../models';
|
||||
import {
|
||||
LlmRequest,
|
||||
@@ -404,7 +404,7 @@ test('doc_read should return specific sync errors for unavailable docs', async t
|
||||
user: () => ({
|
||||
workspace: () => ({ doc: () => ({ can: async () => true }) }),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
for (const testCase of cases) {
|
||||
let docReaderCalled = false;
|
||||
@@ -447,7 +447,7 @@ test('document search tools should return sync error for local workspace', async
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -510,7 +510,7 @@ test('doc_semantic_search should return empty array when nothing matches', async
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -542,7 +542,7 @@ test('doc_semantic_search should pass BYOK route context into embedding matches'
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -595,7 +595,7 @@ test('blob_read should return explicit error when attachment context is missing'
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const blobTool = createBlobReadTool(
|
||||
buildBlobContentGetter(ac, null).bind(null, {
|
||||
|
||||
@@ -57,6 +57,21 @@ function getSnapshot(timestamp: number = Date.now()): DocRecord {
|
||||
};
|
||||
}
|
||||
|
||||
test('history max age converts quota seconds to milliseconds', async t => {
|
||||
Sinon.restore();
|
||||
const options = m.get(DocStorageOptions);
|
||||
// @ts-expect-error private service boundary is asserted here
|
||||
Sinon.stub(options.quota, 'getWorkspaceQuota').resolves({
|
||||
name: 'Pro',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
historyPeriod: 30,
|
||||
memberLimit: 1,
|
||||
});
|
||||
|
||||
t.is(await options.historyMaxAge('1'), 30_000);
|
||||
});
|
||||
|
||||
test('should create doc history if never created before', async t => {
|
||||
// @ts-expect-error private method
|
||||
Sinon.stub(adapter, 'lastDocHistory').resolves(null);
|
||||
|
||||
@@ -273,16 +273,64 @@ e2e('should update comment work', async t => {
|
||||
t.truthy(result.updateComment);
|
||||
});
|
||||
|
||||
e2e('should update comment failed by another user', async t => {
|
||||
e2e('should update comment work by doc Editor', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Editor,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const result = await app.gql({
|
||||
query: updateCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: createResult.createComment.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test update' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.updateComment);
|
||||
});
|
||||
|
||||
e2e('should update comment failed without update permission', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
@@ -1145,15 +1193,79 @@ e2e('should update reply work when user is reply owner', async t => {
|
||||
t.truthy(result.updateReply);
|
||||
});
|
||||
|
||||
e2e('should update reply failed when user is not reply owner', async t => {
|
||||
e2e('should update reply work by doc Editor', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Editor,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createReplyResult = await app.gql({
|
||||
query: createReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
commentId: createResult.createComment.id,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const result = await app.gql({
|
||||
query: updateReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: createReplyResult.createReply.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test update' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.updateReply);
|
||||
});
|
||||
|
||||
e2e('should update reply failed without update permission', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
|
||||
@@ -28,37 +28,43 @@ e2e('should render doc share page with apple-itunes-app meta tag', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
e2e(
|
||||
e2e.serial(
|
||||
'should render doc share page without apple-itunes-app meta tag when selfhosted',
|
||||
async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error override
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
await using app = await createApp();
|
||||
try {
|
||||
await using app = await createApp();
|
||||
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
// set public to true
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
public: true,
|
||||
});
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
// set public to true
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
public: true,
|
||||
});
|
||||
|
||||
const res = await app
|
||||
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', 'text/html; charset=utf-8');
|
||||
const res = await app
|
||||
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', 'text/html; charset=utf-8');
|
||||
|
||||
t.notRegex(
|
||||
res.text,
|
||||
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
|
||||
);
|
||||
t.notRegex(
|
||||
res.text,
|
||||
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -69,6 +69,64 @@ e2e('should get recently updated docs', async t => {
|
||||
t.is(recentlyUpdatedDocs.edges[2].node.title, doc1.title);
|
||||
});
|
||||
|
||||
e2e('should filter recently updated docs by doc read permission', async t => {
|
||||
const owner = await app.signup();
|
||||
const member = await app.createUser();
|
||||
await app.login(member);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
});
|
||||
|
||||
const privateSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: privateSnapshot.id,
|
||||
title: 'private-doc',
|
||||
defaultRole: DocRole.None,
|
||||
});
|
||||
|
||||
const publicSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
const publicDoc = await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: publicSnapshot.id,
|
||||
title: 'public-doc',
|
||||
defaultRole: DocRole.None,
|
||||
public: true,
|
||||
});
|
||||
|
||||
await app.switchUser(member);
|
||||
const {
|
||||
workspace: { recentlyUpdatedDocs },
|
||||
} = await app.gql({
|
||||
query: getRecentlyUpdatedDocsQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
pagination: {
|
||||
first: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(recentlyUpdatedDocs.totalCount, 1);
|
||||
t.deepEqual(
|
||||
recentlyUpdatedDocs.edges.map(edge => edge.node.id),
|
||||
[publicDoc.docId]
|
||||
);
|
||||
});
|
||||
|
||||
e2e(
|
||||
'should get doc with public attribute when doc snapshot not exists',
|
||||
async t => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
listNotificationsQuery,
|
||||
MentionNotificationBodyType,
|
||||
mentionUserMutation,
|
||||
notificationCountQuery,
|
||||
NotificationObjectType,
|
||||
NotificationType,
|
||||
readAllNotificationsMutation,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
async function init() {
|
||||
@@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => {
|
||||
},
|
||||
});
|
||||
}
|
||||
const count = await app.gql({
|
||||
query: notificationCountQuery,
|
||||
});
|
||||
t.is(count.currentUser!.notificationCount, 0);
|
||||
const socket = await createRealtimeClient(app, member);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const count = await realtimeRequest(socket, 'notification.count.get', {});
|
||||
t.is(count.count, 0);
|
||||
|
||||
// read again should work
|
||||
for (const notification of notifications) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
RealtimeAck,
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
} from '@affine/realtime';
|
||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import type { Response } from 'supertest';
|
||||
|
||||
import type { MockedUser } from '../mocks';
|
||||
import type { TestingApp } from './create-app';
|
||||
|
||||
const REALTIME_CLIENT_VERSION = '0.26.0';
|
||||
const WS_TIMEOUT_MS = 5_000;
|
||||
|
||||
function cookieHeader(res: Response) {
|
||||
return (res.get('Set-Cookie') ?? [])
|
||||
.map(cookie => cookie.split(';')[0])
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
) {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnect(socket: SocketIOClient) {
|
||||
if (socket.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
'realtime socket connect'
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
|
||||
const login = await app.login(user);
|
||||
const socket = io(app.url, {
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
forceNew: true,
|
||||
extraHeaders: {
|
||||
cookie: cookieHeader(login),
|
||||
},
|
||||
});
|
||||
await waitForConnect(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
export async function realtimeRequest<Op extends RealtimeRequestName>(
|
||||
socket: SocketIOClient,
|
||||
op: Op,
|
||||
input: RealtimeRequestInputOf<Op>
|
||||
): Promise<RealtimeRequestOutputOf<Op>> {
|
||||
const ack = await withTimeout(
|
||||
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
|
||||
socket.emit(
|
||||
'realtime:request',
|
||||
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
|
||||
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
|
||||
);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
`realtime request ${op}`
|
||||
);
|
||||
|
||||
if ('error' in ack) {
|
||||
throw new Error(`${ack.error.name}: ${ack.error.message}`);
|
||||
}
|
||||
|
||||
return ack.data;
|
||||
}
|
||||
@@ -15,9 +15,18 @@ import {
|
||||
R2StorageProvider,
|
||||
} from '../../../base/storage/providers/r2';
|
||||
import { SIGNED_URL_EXPIRED } from '../../../base/storage/providers/utils';
|
||||
import { WorkspaceBlobStorage } from '../../../core/storage';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import {
|
||||
CommentAttachmentStorage,
|
||||
WorkspaceBlobStorage,
|
||||
} from '../../../core/storage';
|
||||
import { MULTIPART_THRESHOLD } from '../../../core/storage/constants';
|
||||
import { R2UploadController } from '../../../core/storage/r2-proxy';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { app, e2e, Mockers } from '../test';
|
||||
|
||||
class MockR2Provider extends R2StorageProvider {
|
||||
@@ -160,6 +169,8 @@ async function setBlobStorage(storage: StorageProviderConfig) {
|
||||
configFactory.override({ storages: { blob: { storage } } });
|
||||
const blobStorage = app.get(WorkspaceBlobStorage);
|
||||
await blobStorage.onConfigInit();
|
||||
const commentAttachmentStorage = app.get(CommentAttachmentStorage);
|
||||
await commentAttachmentStorage.onConfigInit();
|
||||
const controller = app.get(R2UploadController);
|
||||
// reset cached provider in controller
|
||||
(controller as any).provider = null;
|
||||
@@ -245,7 +256,13 @@ async function getBlobUploadPartUrl(
|
||||
}
|
||||
|
||||
async function setupWorkspace() {
|
||||
const owner = await app.signup({ feature: 'pro_plan_v1' });
|
||||
const owner = await app.signup();
|
||||
await app.get(EntitlementService).upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
return { owner, workspace };
|
||||
}
|
||||
@@ -435,7 +452,13 @@ e2e(
|
||||
e2e(
|
||||
'should still fallback to graphql when provider does not support presign',
|
||||
async t => {
|
||||
await setBlobStorage(defaultBlobStorage);
|
||||
await setBlobStorage({
|
||||
provider: 'fs',
|
||||
bucket: 'test-fallback-bucket',
|
||||
config: {
|
||||
path: '/tmp/affine-r2-proxy-test',
|
||||
},
|
||||
});
|
||||
const { workspace } = await setupWorkspace();
|
||||
const buffer = Buffer.from('graph');
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import {
|
||||
Config,
|
||||
ConfigFactory,
|
||||
type StorageProviderConfig,
|
||||
} from '../../../base';
|
||||
import { CommentAttachmentStorage } from '../../../core/storage';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
@@ -21,6 +26,11 @@ e2e.afterEach.always(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
async function useCommentAttachmentBlobStorage(storage: StorageProviderConfig) {
|
||||
app.get(ConfigFactory).override({ storages: { blob: { storage } } });
|
||||
await app.get(CommentAttachmentStorage).onConfigInit();
|
||||
}
|
||||
|
||||
// #region comment attachment
|
||||
|
||||
e2e(
|
||||
@@ -61,35 +71,50 @@ e2e(
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should get comment attachment body', async t => {
|
||||
e2e.serial('should get comment attachment body', async t => {
|
||||
const defaultBlobStorage = structuredClone(
|
||||
app.get(Config).storages.blob.storage
|
||||
);
|
||||
await useCommentAttachmentBlobStorage({
|
||||
provider: 'fs',
|
||||
bucket: 'test-comment-attachment',
|
||||
config: {
|
||||
path: '/tmp/affine-test-comment-attachment',
|
||||
},
|
||||
});
|
||||
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
await app.login(owner);
|
||||
|
||||
const docId = randomUUID();
|
||||
const key = randomUUID();
|
||||
const attachment = app.get(CommentAttachmentStorage);
|
||||
await attachment.put(
|
||||
workspace.id,
|
||||
docId,
|
||||
key,
|
||||
'test.txt',
|
||||
Buffer.from('test'),
|
||||
owner.id
|
||||
);
|
||||
try {
|
||||
const docId = randomUUID();
|
||||
const key = randomUUID();
|
||||
const attachment = app.get(CommentAttachmentStorage);
|
||||
await attachment.put(
|
||||
workspace.id,
|
||||
docId,
|
||||
key,
|
||||
'test.txt',
|
||||
Buffer.from('test'),
|
||||
owner.id
|
||||
);
|
||||
|
||||
const res = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
|
||||
);
|
||||
const res = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
|
||||
);
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.is(res.headers['content-type'], 'text/plain');
|
||||
t.is(res.headers['content-length'], '4');
|
||||
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
|
||||
t.regex(
|
||||
res.headers['last-modified'],
|
||||
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
|
||||
);
|
||||
t.is(res.text, 'test');
|
||||
t.is(res.status, 200);
|
||||
t.is(res.headers['content-type'], 'text/plain');
|
||||
t.is(res.headers['content-length'], '4');
|
||||
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
|
||||
t.regex(
|
||||
res.headers['last-modified'],
|
||||
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
|
||||
);
|
||||
t.is(res.text, 'test');
|
||||
} finally {
|
||||
await useCommentAttachmentBlobStorage(defaultBlobStorage);
|
||||
}
|
||||
});
|
||||
|
||||
e2e('should get comment attachment redirect url', async t => {
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
acceptInviteByInviteIdMutation,
|
||||
approveWorkspaceTeamMemberMutation,
|
||||
createInviteLinkMutation,
|
||||
deleteBlobMutation,
|
||||
getInviteInfoQuery,
|
||||
getMembersByWorkspaceIdQuery,
|
||||
inviteByEmailsMutation,
|
||||
leaveWorkspaceMutation,
|
||||
releaseDeletedBlobsMutation,
|
||||
revokeMemberPermissionMutation,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import {
|
||||
WorkspaceMemberSource,
|
||||
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { Models } from '../../../models';
|
||||
import { FeatureConfigs } from '../../../models/common/feature';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../../core/permission';
|
||||
import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
const TWO_BILLION_BYTES = 2_000_000_000;
|
||||
|
||||
async function createWorkspace() {
|
||||
const owner = await app.create(Mockers.User);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
@@ -35,6 +43,23 @@ async function createWorkspace() {
|
||||
};
|
||||
}
|
||||
|
||||
async function grantTeamPlan(workspaceId: string, quantity: number) {
|
||||
await app.get(EntitlementService).upsertFromCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeTeamPlan(workspaceId: string) {
|
||||
await app.get(EntitlementService).revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
});
|
||||
}
|
||||
|
||||
e2e('should invite a user', async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const u2 = await app.create(Mockers.User);
|
||||
@@ -91,19 +116,16 @@ e2e('should invite a user', async t => {
|
||||
e2e('should re-check seat when accepting an email invitation', async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const member = await app.create(Mockers.User);
|
||||
await app.create(Mockers.TeamWorkspace, {
|
||||
id: workspace.id,
|
||||
quantity: 4,
|
||||
});
|
||||
await grantTeamPlan(workspace.id, 12);
|
||||
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }).map(async () => {
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const invite = await app.gql({
|
||||
@@ -116,10 +138,10 @@ e2e('should re-check seat when accepting an email invitation', async t => {
|
||||
|
||||
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
||||
workspaceId: workspace.id,
|
||||
quantity: 4,
|
||||
quantity: 12,
|
||||
});
|
||||
|
||||
await app.models.workspaceFeature.remove(workspace.id, 'team_plan_v1');
|
||||
await revokeTeamPlan(workspace.id);
|
||||
|
||||
await app.login(member);
|
||||
await t.throwsAsync(
|
||||
@@ -147,24 +169,6 @@ e2e.serial(
|
||||
async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const member = await app.create(Mockers.User);
|
||||
const freeStorageQuota = FeatureConfigs.free_plan_v1.configs.storageQuota;
|
||||
const lifetimeStorageQuota =
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota;
|
||||
|
||||
FeatureConfigs.free_plan_v1.configs.storageQuota = 1;
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = 2;
|
||||
t.teardown(() => {
|
||||
FeatureConfigs.free_plan_v1.configs.storageQuota = freeStorageQuota;
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota =
|
||||
lifetimeStorageQuota;
|
||||
});
|
||||
|
||||
await app.models.userFeature.switchQuota(
|
||||
owner.id,
|
||||
'lifetime_pro_plan_v1',
|
||||
'test setup'
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const invite = await app.gql({
|
||||
query: inviteByEmailsMutation,
|
||||
@@ -174,26 +178,26 @@ e2e.serial(
|
||||
},
|
||||
});
|
||||
|
||||
await app.models.blob.upsert({
|
||||
workspaceId: workspace.id,
|
||||
key: 'overflow-blob',
|
||||
mime: 'application/octet-stream',
|
||||
size: 2,
|
||||
status: 'completed',
|
||||
uploadId: null,
|
||||
});
|
||||
|
||||
await app.eventBus.emitAsync('user.subscription.canceled', {
|
||||
userId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
});
|
||||
const overflowBlobKeys = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `overflow-blob-${index}`
|
||||
);
|
||||
await Promise.all(
|
||||
overflowBlobKeys.map(key =>
|
||||
app.models.blob.upsert({
|
||||
workspaceId: workspace.id,
|
||||
key,
|
||||
mime: 'application/octet-stream',
|
||||
size: TWO_BILLION_BYTES,
|
||||
status: 'completed',
|
||||
uploadId: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
t.true(
|
||||
await app.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||
.isReadonly
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
@@ -216,26 +220,13 @@ e2e.serial(
|
||||
t.is(pendingInvite.status, WorkspaceMemberStatus.Pending);
|
||||
|
||||
await app.login(owner);
|
||||
await app.gql({
|
||||
query: deleteBlobMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
key: 'overflow-blob',
|
||||
permanently: false,
|
||||
},
|
||||
});
|
||||
await app.gql({
|
||||
query: releaseDeletedBlobsMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
for (const key of overflowBlobKeys) {
|
||||
await app.models.blob.delete(workspace.id, key, true);
|
||||
}
|
||||
|
||||
t.false(
|
||||
await app.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||
.isReadonly
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
@@ -393,39 +384,31 @@ e2e('should support pagination for member', async t => {
|
||||
userId: u2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 2);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 2);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 0);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 0);
|
||||
});
|
||||
|
||||
e2e('should limit member count correctly', async t => {
|
||||
@@ -441,17 +424,15 @@ e2e('should limit member count correctly', async t => {
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 11);
|
||||
t.is(result.workspace.members.length, 10);
|
||||
t.is(result.memberCount, 11);
|
||||
t.is(result.members.length, 10);
|
||||
});
|
||||
|
||||
e2e('should get invite link info with status', async t => {
|
||||
@@ -596,10 +577,7 @@ e2e(
|
||||
'should invite by link and send review request notification over quota limit',
|
||||
async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
await app.create(Mockers.TeamWorkspace, {
|
||||
id: workspace.id,
|
||||
quantity: 3,
|
||||
});
|
||||
await grantTeamPlan(workspace.id, 3);
|
||||
|
||||
await app.login(owner);
|
||||
const { createInviteLink } = await app.gql({
|
||||
@@ -639,10 +617,7 @@ e2e(
|
||||
name: faker.internet.displayName({ firstName: 'Lucy' }),
|
||||
});
|
||||
const user2 = await app.create(Mockers.User, {
|
||||
email: faker.internet.email({
|
||||
firstName: 'Jeanne',
|
||||
lastName: 'Doe',
|
||||
}),
|
||||
email: `jeanne_doe.${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
@@ -653,38 +628,54 @@ e2e(
|
||||
userId: user2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].email, user2.email);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, user2.email);
|
||||
|
||||
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
|
||||
const pendingUser = await app.create(Mockers.User, {
|
||||
email: pendingEmail,
|
||||
});
|
||||
await app
|
||||
.get(Models)
|
||||
.workspaceUser.set(
|
||||
workspace.id,
|
||||
pendingUser.id,
|
||||
ModelWorkspaceRole.Collaborator,
|
||||
{
|
||||
status: PrismaWorkspaceMemberStatus.Pending,
|
||||
source: WorkspaceMemberSource.Email,
|
||||
}
|
||||
);
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'pending_search',
|
||||
});
|
||||
t.is(result.memberCount, 4);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, pendingEmail);
|
||||
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
revokePublicPageMutation,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { QuotaService } from '../../../core/quota/service';
|
||||
import { WorkspaceRole } from '../../../models';
|
||||
@@ -98,7 +99,31 @@ const revokeMember = async (workspaceId: string, userId: string) => {
|
||||
return revokeMember;
|
||||
};
|
||||
|
||||
e2e('should set new invited users to AllocatingSeat', async t => {
|
||||
const cancelTeamWorkspace = async (workspaceId: string) => {
|
||||
const db = app.get(PrismaClient);
|
||||
await db.entitlement.updateMany({
|
||||
where: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: 'team',
|
||||
},
|
||||
data: { status: 'revoked' },
|
||||
});
|
||||
await db.subscription.updateMany({
|
||||
where: {
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
},
|
||||
data: { status: 'canceled' },
|
||||
});
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
};
|
||||
|
||||
e2e('should set new invited users to waiting-seat status', async t => {
|
||||
const { owner, workspace } = await createTeamWorkspace();
|
||||
await app.login(owner);
|
||||
|
||||
@@ -117,7 +142,7 @@ e2e('should set new invited users to AllocatingSeat', async t => {
|
||||
const invitationInfo = await getInvitationInfo(
|
||||
result.inviteMembers[0].inviteId!
|
||||
);
|
||||
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
|
||||
t.is(invitationInfo.status, WorkspaceMemberStatus.NeedMoreSeat);
|
||||
});
|
||||
|
||||
e2e('should allocate seats', async t => {
|
||||
@@ -151,11 +176,11 @@ e2e('should allocate seats', async t => {
|
||||
});
|
||||
|
||||
t.is(
|
||||
members.find(m => m.user.id === u1.id)?.status,
|
||||
members.find(m => m.user?.id === u1.id)?.status,
|
||||
WorkspaceMemberStatus.Pending
|
||||
);
|
||||
t.is(
|
||||
members.find(m => m.user.id === u2.id)?.status,
|
||||
members.find(m => m.user?.id === u2.id)?.status,
|
||||
WorkspaceMemberStatus.Accepted
|
||||
);
|
||||
|
||||
@@ -201,11 +226,11 @@ e2e('should set all rests to NeedMoreSeat', async t => {
|
||||
});
|
||||
|
||||
t.is(
|
||||
members.find(m => m.user.id === u2.id)?.status,
|
||||
members.find(m => m.user?.id === u2.id)?.status,
|
||||
WorkspaceMemberStatus.NeedMoreSeat
|
||||
);
|
||||
t.is(
|
||||
members.find(m => m.user.id === u3.id)?.status,
|
||||
members.find(m => m.user?.id === u3.id)?.status,
|
||||
WorkspaceMemberStatus.NeedMoreSeat
|
||||
);
|
||||
});
|
||||
@@ -237,11 +262,7 @@ e2e(
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
|
||||
first: 20,
|
||||
@@ -265,11 +286,7 @@ e2e(
|
||||
async t => {
|
||||
const { workspace, owner, admin } = await createTeamWorkspace();
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
|
||||
t.false(
|
||||
@@ -306,11 +323,7 @@ e2e(
|
||||
await app.login(owner);
|
||||
await publishDoc(workspace.id, 'published-doc');
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
|
||||
t.true(
|
||||
@@ -325,7 +338,7 @@ e2e(
|
||||
);
|
||||
|
||||
await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc'));
|
||||
await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc'));
|
||||
await t.throwsAsync(revokePublicDoc(workspace.id, 'published-doc'));
|
||||
|
||||
const quota = await app
|
||||
.get(QuotaService)
|
||||
|
||||
@@ -27,6 +27,16 @@ export class MockTeamWorkspace extends Mocker<
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
await this.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'workspace',
|
||||
targetId: id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'team',
|
||||
status: 'active',
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.workspaceFeature.create({
|
||||
data: {
|
||||
|
||||
@@ -45,6 +45,55 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
const runtimeStateColumns = await this.db.$queryRaw<
|
||||
Array<{ exists: boolean }>
|
||||
>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_runtime_states'
|
||||
AND column_name = 'known'
|
||||
) AS "exists"
|
||||
`;
|
||||
if (runtimeStateColumns[0]?.exists) {
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO workspace_runtime_states (
|
||||
workspace_id,
|
||||
known,
|
||||
readonly,
|
||||
readonly_reasons,
|
||||
last_reconciled_at,
|
||||
stale_after,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
known = true,
|
||||
readonly = false,
|
||||
readonly_reasons = ARRAY[]::TEXT[],
|
||||
last_reconciled_at = now(),
|
||||
stale_after = NULL,
|
||||
updated_at = now()
|
||||
`;
|
||||
} else {
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO workspace_runtime_states (
|
||||
workspace_id,
|
||||
readonly,
|
||||
readonly_reasons,
|
||||
stale_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
readonly = false,
|
||||
readonly_reasons = ARRAY[]::TEXT[],
|
||||
stale_at = NULL,
|
||||
updated_at = now()
|
||||
`;
|
||||
}
|
||||
|
||||
// create a rootDoc snapshot
|
||||
if (snapshot) {
|
||||
|
||||
@@ -73,6 +73,24 @@ test('should set doc user role', async t => {
|
||||
t.is(role?.type, DocRole.Manager);
|
||||
});
|
||||
|
||||
test('should batch update existing doc user roles', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
await models.docUser.set(workspace.id, docId, user.id, DocRole.Reader);
|
||||
const count = await models.docUser.batchSetUserRoles(
|
||||
workspace.id,
|
||||
docId,
|
||||
[user.id],
|
||||
DocRole.Editor
|
||||
);
|
||||
const role = await models.docUser.get(workspace.id, docId, user.id);
|
||||
|
||||
t.is(count, 1);
|
||||
t.is(role?.type, DocRole.Editor);
|
||||
});
|
||||
|
||||
test('should not allow setting doc owner through setDocUserRole', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
@@ -96,6 +114,23 @@ test('should delete doc user role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should delete doc grants by user id', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
|
||||
await models.docUser.deleteByUserId(user.id);
|
||||
|
||||
t.is(await models.docUser.get(workspace.id, docId, user.id), null);
|
||||
t.is(
|
||||
await db.docGrant.count({
|
||||
where: { principalType: 'user', principalId: user.id },
|
||||
}),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('should paginate doc user roles', async t => {
|
||||
const workspace = await create();
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
|
||||
import { AvailableUserFeatureConfig } from '../../core/features/types';
|
||||
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
|
||||
import { Feature } from '../../models/common/feature';
|
||||
import { createTestingModule, TestingModule } from '../utils';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
model: UserFeatureModel;
|
||||
resolver: AdminFeatureManagementResolver;
|
||||
u1: User;
|
||||
}
|
||||
|
||||
@@ -16,6 +20,7 @@ test.before(async t => {
|
||||
const module = await createTestingModule({});
|
||||
|
||||
t.context.model = module.get(UserFeatureModel);
|
||||
t.context.resolver = module.get(AdminFeatureManagementResolver);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
@@ -31,6 +36,21 @@ test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('configurable user features exclude commercial projection features', t => {
|
||||
const config = new AvailableUserFeatureConfig();
|
||||
|
||||
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
|
||||
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
|
||||
});
|
||||
|
||||
test('admin feature resolver rejects commercial projection features', async t => {
|
||||
await t.throwsAsync(
|
||||
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
|
||||
{ message: /not configurable/ }
|
||||
);
|
||||
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
|
||||
});
|
||||
|
||||
test('should get null if user feature not found', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
const userFeature = await model.get(u1.id, 'ai_early_access');
|
||||
@@ -39,12 +59,14 @@ test('should get null if user feature not found', async t => {
|
||||
|
||||
test('should get user feature', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const userFeature = await model.get(u1.id, 'free_plan_v1');
|
||||
t.is(userFeature?.name, 'free_plan_v1');
|
||||
});
|
||||
|
||||
test('should get user quota', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const userQuota = await model.getQuota(u1.id);
|
||||
t.snapshot(userQuota?.configs, 'free plan');
|
||||
});
|
||||
@@ -52,6 +74,7 @@ test('should get user quota', async t => {
|
||||
test('should list user features', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
t.like(await model.list(u1.id), ['free_plan_v1']);
|
||||
});
|
||||
|
||||
@@ -68,6 +91,7 @@ test('should list user features by type', async t => {
|
||||
test('should directly test user feature existence', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
t.true(await model.has(u1.id, 'free_plan_v1'));
|
||||
t.false(await model.has(u1.id, 'ai_early_access'));
|
||||
});
|
||||
@@ -112,6 +136,7 @@ test('should switch user quota', async t => {
|
||||
test('should not switch user quota if the new quota is the same as the current one', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
|
||||
|
||||
// @ts-expect-error private
|
||||
@@ -135,6 +160,7 @@ test('should use pro plan as free for selfhost instance', async t => {
|
||||
registered: true,
|
||||
});
|
||||
|
||||
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const quota = await models.userFeature.getQuota(u1.id);
|
||||
t.snapshot(
|
||||
quota?.configs,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Workspace } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
|
||||
import {
|
||||
FeatureType,
|
||||
UserModel,
|
||||
@@ -12,6 +13,7 @@ import { createTestingModule, type TestingModule } from '../utils';
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
model: WorkspaceFeatureModel;
|
||||
resolver: AdminWorkspaceResolver;
|
||||
ws: Workspace;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ test.before(async t => {
|
||||
const module = await createTestingModule({});
|
||||
|
||||
t.context.model = module.get(WorkspaceFeatureModel);
|
||||
t.context.resolver = module.get(AdminWorkspaceResolver);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
@@ -44,6 +47,17 @@ test('should get null if workspace feature not found', async t => {
|
||||
t.is(userFeature, null);
|
||||
});
|
||||
|
||||
test('admin workspace update changes workspace flags', async t => {
|
||||
await t.context.resolver.adminUpdateWorkspace({
|
||||
id: t.context.ws.id,
|
||||
name: 'updated',
|
||||
});
|
||||
t.is(
|
||||
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
|
||||
'updated'
|
||||
);
|
||||
});
|
||||
|
||||
test('should directly test workspace feature existence', async t => {
|
||||
const { model, ws } = t.context;
|
||||
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
|
||||
import {
|
||||
DocRole,
|
||||
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
|
||||
PermissionProjectionModel,
|
||||
permissionProjectionTriggerErrorCategory,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../models';
|
||||
import { createModule } from '../create-module';
|
||||
import { Mockers } from '../mocks';
|
||||
|
||||
const module = await createModule({});
|
||||
const db = module.get(PrismaClient);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
class TestPermissionProjectionModel extends PermissionProjectionModel {
|
||||
constructor(private readonly fakeDb: unknown) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected override get db() {
|
||||
return this.fakeDb as never;
|
||||
}
|
||||
}
|
||||
|
||||
let appliedPermissionProjectionTriggerFunctionUpdates = false;
|
||||
async function applyPermissionProjectionTriggerFunctionUpdates() {
|
||||
if (appliedPermissionProjectionTriggerFunctionUpdates) {
|
||||
return;
|
||||
}
|
||||
const migration = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'migrations/20260512133700_workspace_runtime_states/migration.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
for (const name of [
|
||||
'affine_permission_project_new_workspace_member',
|
||||
'affine_permission_project_new_workspace_invitation',
|
||||
'affine_permission_project_new_doc_access_policy',
|
||||
'affine_permission_project_new_doc_grant',
|
||||
]) {
|
||||
const sql = migration.match(
|
||||
new RegExp(
|
||||
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
|
||||
)
|
||||
)?.[0];
|
||||
if (!sql) {
|
||||
throw new Error(`Missing migration function ${name}`);
|
||||
}
|
||||
await db.$executeRawUnsafe(sql);
|
||||
}
|
||||
appliedPermissionProjectionTriggerFunctionUpdates = true;
|
||||
}
|
||||
|
||||
async function hasCurrentWorkspaceInvitationColumns() {
|
||||
const rows = await db.$queryRaw<{ columnName: string }[]>`
|
||||
SELECT column_name AS "columnName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_invitations'
|
||||
AND column_name IN ('requested_role', 'status', 'kind')
|
||||
`;
|
||||
return rows.length === 3;
|
||||
}
|
||||
|
||||
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
|
||||
const queryResults = [
|
||||
[{ count: 1n }],
|
||||
[{ count: 2n }],
|
||||
[{ count: 3n }],
|
||||
[{ count: 4n }],
|
||||
[{ count: 5n }],
|
||||
[{ count: 6n }],
|
||||
[{ count: 7n }],
|
||||
[{ count: 8n }],
|
||||
[{ count: 9n }],
|
||||
[{ count: 10n }],
|
||||
[
|
||||
{ category: 'legacy_doc_external_row', count: 11n },
|
||||
{ category: 'doc_default_owner', count: 12n },
|
||||
],
|
||||
];
|
||||
const model = new TestPermissionProjectionModel({
|
||||
$queryRaw: async () => queryResults.shift(),
|
||||
});
|
||||
|
||||
t.deepEqual(await model.checkLegacyProjection(), {
|
||||
oldWorkspacePolicyMismatch: 1,
|
||||
oldAcceptedMemberMismatch: 2,
|
||||
extraProjectedMember: 3,
|
||||
oldInvitationMismatch: 4,
|
||||
extraProjectedInvitation: 5,
|
||||
oldDocGrantMismatch: 6,
|
||||
extraProjectedDocGrant: 7,
|
||||
oldDocPolicyMismatch: 8,
|
||||
extraProjectedDocPolicy: 9,
|
||||
runtimeStateMissing: 0,
|
||||
runtimeStateMismatch: 0,
|
||||
ownerConflict: 10,
|
||||
oldNewDecisionMismatch: 0,
|
||||
invalidLegacyRows: {
|
||||
legacy_doc_external_row: 11,
|
||||
doc_default_owner: 12,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => {
|
||||
const executed: unknown[] = [];
|
||||
let transactionOptions: unknown;
|
||||
const model = new TestPermissionProjectionModel({
|
||||
$transaction: async (
|
||||
callback: (tx: unknown) => Promise<void>,
|
||||
options: unknown
|
||||
) => {
|
||||
transactionOptions = options;
|
||||
await callback({
|
||||
$executeRaw: async (query: unknown) => {
|
||||
executed.push(query);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await model.backfillLegacyProjection();
|
||||
|
||||
t.is(executed.length, 11);
|
||||
t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 });
|
||||
t.regex(String(executed[0]), /affine\.permission_sync_origin/);
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
|
||||
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
|
||||
'owner_conflict',
|
||||
'invalid_legacy_role',
|
||||
'foreign_key_missing',
|
||||
'projection_recursion_guard_missing',
|
||||
'unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
test('permission projection migration uses non-recursive origin guard', t => {
|
||||
const migration = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'migrations/20260512133700_workspace_runtime_states/migration.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const guardBody = migration.match(
|
||||
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
|
||||
)?.[0];
|
||||
|
||||
t.truthy(guardBody);
|
||||
t.true(
|
||||
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
|
||||
);
|
||||
t.false(
|
||||
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
|
||||
);
|
||||
t.truthy(
|
||||
migration.match(
|
||||
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger maps legacy workspace permission rows', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const [admin, pending] = await module.create(Mockers.User, 2);
|
||||
|
||||
await db.workspaceUserRole.createMany({
|
||||
data: [
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId: admin.id,
|
||||
type: WorkspaceRole.Admin,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId: pending.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const member = await db.workspaceMember.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: admin.id,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_inviteeUserId: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: pending.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(member.role, 'admin');
|
||||
t.is(invitation.requestedRole, 'member');
|
||||
t.is(invitation.status, 'pending');
|
||||
});
|
||||
|
||||
test('permission projection trigger maps legacy doc policy rows', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
await db.workspaceDoc.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'public-doc',
|
||||
public: true,
|
||||
defaultRole: DocRole.Reader,
|
||||
},
|
||||
});
|
||||
|
||||
const policy = await db.docAccessPolicy.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'public-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(policy.visibility, 'public');
|
||||
t.is(policy.publicRole, 'external');
|
||||
t.is(policy.memberDefaultRole, 'reader');
|
||||
});
|
||||
|
||||
async function hasDocGrantLegacyProjectionColumns() {
|
||||
const rows = await db.$queryRaw<{ columnName: string }[]>`
|
||||
SELECT column_name AS "columnName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'doc_grants'
|
||||
AND column_name IN (
|
||||
'legacy_workspace_id',
|
||||
'legacy_doc_id',
|
||||
'legacy_user_id'
|
||||
)
|
||||
`;
|
||||
return rows.length === 3;
|
||||
}
|
||||
|
||||
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
|
||||
if (!(await hasDocGrantLegacyProjectionColumns())) {
|
||||
t.false(
|
||||
Boolean(process.env.CI),
|
||||
'current local test database predates doc_grants legacy columns'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await db.workspaceDocUserRole.createMany({
|
||||
data: [
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'valid-grant',
|
||||
userId: user.id,
|
||||
type: DocRole.Reader,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'dirty-external',
|
||||
userId: user.id,
|
||||
type: DocRole.External,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'dirty-none',
|
||||
userId: user.id,
|
||||
type: DocRole.None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const grants = await db.docGrant.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
principalId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
docId: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
t.deepEqual(
|
||||
grants.map(grant => [grant.docId, grant.role]),
|
||||
[['valid-grant', 'reader']]
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const member = await db.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role: 'member',
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await db.workspaceMember.update({
|
||||
where: { id: member.id },
|
||||
data: { state: 'suspended' },
|
||||
});
|
||||
|
||||
t.is(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
|
||||
if (!(await hasCurrentWorkspaceInvitationColumns())) {
|
||||
t.false(
|
||||
Boolean(process.env.CI),
|
||||
'current local test database predates workspace invitation projection columns'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const [invitation] = await db.$queryRaw<{ id: string }[]>`
|
||||
INSERT INTO workspace_invitations (
|
||||
workspace_id,
|
||||
invitee_user_id,
|
||||
requested_role,
|
||||
status,
|
||||
kind
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
${user.id},
|
||||
'member',
|
||||
'pending',
|
||||
'email'
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
t.is(
|
||||
(
|
||||
await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
).status,
|
||||
'Pending'
|
||||
);
|
||||
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_invitations
|
||||
SET status = 'declined'
|
||||
WHERE id = ${invitation.id}
|
||||
`;
|
||||
|
||||
t.is(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
await db.workspaceDoc.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
public: true,
|
||||
defaultRole: DocRole.Reader,
|
||||
mode: 1,
|
||||
blocked: true,
|
||||
title: 'Title',
|
||||
summary: 'Summary',
|
||||
publishedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await db.docAccessPolicy.delete({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const doc = await db.workspaceDoc.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(doc.public, false);
|
||||
t.is(doc.defaultRole, DocRole.Manager);
|
||||
t.is(doc.publishedAt, null);
|
||||
t.is(doc.mode, 1);
|
||||
t.is(doc.blocked, true);
|
||||
t.is(doc.title, 'Title');
|
||||
t.is(doc.summary, 'Summary');
|
||||
});
|
||||
|
||||
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await db.docGrant.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'user',
|
||||
principalId: user.id,
|
||||
role: 'reader',
|
||||
},
|
||||
});
|
||||
await db.docGrant.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'group',
|
||||
principalId: user.id,
|
||||
role: 'manager',
|
||||
},
|
||||
});
|
||||
await db.docGrant.delete({
|
||||
where: {
|
||||
workspaceId_docId_principalType_principalId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'group',
|
||||
principalId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(legacyGrant.type, DocRole.Reader);
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel parses trigger error metric category', t => {
|
||||
t.is(
|
||||
permissionProjectionTriggerErrorCategory(
|
||||
new Error('permission_projection_error:owner_conflict:duplicate owner')
|
||||
),
|
||||
'owner_conflict'
|
||||
);
|
||||
t.is(
|
||||
permissionProjectionTriggerErrorCategory(
|
||||
new Error('permission_projection_error:unexpected:nope')
|
||||
),
|
||||
'unknown'
|
||||
);
|
||||
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
|
||||
});
|
||||
|
||||
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
|
||||
const checker = new PermissionProjectionChecker(
|
||||
{
|
||||
workspace: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
$queryRaw: async () => [
|
||||
{
|
||||
category: 'active_member_doc',
|
||||
workspaceId: 'w1',
|
||||
docId: 'doc1',
|
||||
userId: 'u1',
|
||||
workspaceActions: null,
|
||||
docActions: ['Doc.Read'],
|
||||
},
|
||||
{
|
||||
category: 'explicit_doc_grant',
|
||||
workspaceId: 'w1',
|
||||
docId: 'doc2',
|
||||
userId: 'u1',
|
||||
workspaceActions: null,
|
||||
docActions: ['Doc.Read'],
|
||||
},
|
||||
{
|
||||
category: 'workspace_invitation',
|
||||
workspaceId: 'w1',
|
||||
docId: null,
|
||||
userId: 'u2',
|
||||
workspaceActions: ['Workspace.Read'],
|
||||
docActions: null,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
permissionProjection: {
|
||||
checkLegacyProjection: async () => ({}),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
load: async (input: { docs?: [{ docId: string }] }) => ({
|
||||
version: 1,
|
||||
workspace: { marker: 'legacy' },
|
||||
docs: input.docs
|
||||
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
|
||||
: [],
|
||||
}),
|
||||
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
|
||||
version: 1,
|
||||
workspace: { marker: input.docs ? 'legacy' : 'projection' },
|
||||
docs: input.docs
|
||||
? [
|
||||
{
|
||||
docId: input.docs[0].docId,
|
||||
marker:
|
||||
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
evaluate: (input: unknown) => input,
|
||||
} as never
|
||||
);
|
||||
|
||||
t.deepEqual(await checker.checkLegacyProjection(), {
|
||||
oldNewDecisionMismatch: 2,
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,22 @@ test('should not get inactive workspace role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should not activate a missing workspace invitation', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await t.throwsAsync(
|
||||
models.workspaceUser.setStatus(
|
||||
workspace.id,
|
||||
user.id,
|
||||
WorkspaceMemberStatus.Accepted
|
||||
),
|
||||
{ message: 'Cannot activate a missing workspace invitation.' }
|
||||
);
|
||||
|
||||
t.is(await models.workspaceUser.get(workspace.id, user.id), null);
|
||||
});
|
||||
|
||||
test('should update user role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
@@ -215,6 +231,114 @@ test('should delete workspace user role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should delete legacy-only external workspace user role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
|
||||
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
|
||||
|
||||
await models.workspaceUser.delete(workspace.id, u1.id);
|
||||
|
||||
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
|
||||
});
|
||||
|
||||
test('should convert existing workspace user role to legacy-only external role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
|
||||
const role = await models.workspaceUser.get(workspace.id, u1.id);
|
||||
t.is(role?.type, WorkspaceRole.External);
|
||||
t.is(
|
||||
await db.workspaceMember.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
state: 'active',
|
||||
},
|
||||
}),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('should backfill legacy permission id for new workspace member writes', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
|
||||
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const member = await db.workspaceMember.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
t.is(member.legacyPermissionId, legacyRole.id);
|
||||
});
|
||||
|
||||
test('should backfill legacy permission id for new workspace invitation writes', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
}
|
||||
);
|
||||
|
||||
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const invitation = await db.workspaceInvitation.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: u1.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(invitation.legacyPermissionId, legacyRole.id);
|
||||
});
|
||||
|
||||
test('should get user workspace roles with filter', async t => {
|
||||
const ws1 = await module.create(Mockers.Workspace);
|
||||
const ws2 = await module.create(Mockers.Workspace);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { CryptoHelper, EventBus } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { LicenseService } from '../../plugins/license/service';
|
||||
import { PaymentEventHandlers } from '../../plugins/payment/event';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionVariant,
|
||||
} from '../../plugins/payment/types';
|
||||
|
||||
type Context = Record<string, never>;
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test('workspace subscription activation only sends upgrade notification', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
let reconciled = false;
|
||||
const handler = new PaymentEventHandlers(
|
||||
{
|
||||
isTeamWorkspace: async () => true,
|
||||
sendTeamWorkspaceUpgradedEmail: async () => {},
|
||||
} as unknown as WorkspaceService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => {
|
||||
reconciled = true;
|
||||
},
|
||||
} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 7 }),
|
||||
} as unknown as QuotaStateService,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await handler.onWorkspaceSubscriptionUpdated({
|
||||
workspaceId: 'ws',
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
quantity: 999,
|
||||
});
|
||||
|
||||
t.deepEqual(events, []);
|
||||
t.false(reconciled);
|
||||
});
|
||||
|
||||
test('workspace entitlement change allocates seats from effective quota state', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const handler = new PaymentEventHandlers(
|
||||
{} as unknown as WorkspaceService,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({
|
||||
plan: 'team',
|
||||
seatLimit: 7,
|
||||
}),
|
||||
} as unknown as QuotaStateService,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await handler.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: 'ws',
|
||||
});
|
||||
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.members.allocateSeats',
|
||||
payload: { workspaceId: 'ws', quantity: 7 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('onetime selfhost license seat allocation ignores projected license quantity', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const service = new LicenseService(
|
||||
{
|
||||
installedLicense: {
|
||||
findUnique: async () => ({
|
||||
key: 'license-key',
|
||||
workspaceId: 'ws',
|
||||
quantity: 999,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as Models,
|
||||
{} as unknown as CryptoHelper,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{} as unknown as EntitlementService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 4 }),
|
||||
} as unknown as QuotaStateService
|
||||
);
|
||||
|
||||
await service.updateTeamSeats({
|
||||
workspaceId: 'ws',
|
||||
} as Events['workspace.members.updated']);
|
||||
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.members.allocateSeats',
|
||||
payload: { workspaceId: 'ws', quantity: 4 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const affineProRequests: string[] = [];
|
||||
const upserts: unknown[] = [];
|
||||
const entitlements: unknown[] = [];
|
||||
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
const service = new LicenseService(
|
||||
{
|
||||
installedLicense: {
|
||||
findUnique: async () => null,
|
||||
upsert: async (input: unknown) => {
|
||||
upserts.push(input);
|
||||
return {
|
||||
workspaceId: 'ws',
|
||||
key: 'license-key',
|
||||
quantity: 3,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as Models,
|
||||
{} as unknown as CryptoHelper,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
|
||||
entitlements.push(input);
|
||||
},
|
||||
} as unknown as EntitlementService,
|
||||
{} as unknown as QuotaStateService
|
||||
);
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
fetchAffinePro: (path: string) => Promise<{
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
endAt: number;
|
||||
res: Response;
|
||||
}>;
|
||||
}
|
||||
).fetchAffinePro = async (path: string) => {
|
||||
affineProRequests.push(path);
|
||||
return {
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
endAt: expiresAt,
|
||||
res: new Response(null, {
|
||||
headers: {
|
||||
'x-next-validate-key': 'next-validate-key',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const license = await service.activateTeamLicense('ws', 'license-key');
|
||||
|
||||
t.like(license, {
|
||||
workspaceId: 'ws',
|
||||
key: 'license-key',
|
||||
quantity: 3,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
t.is(entitlements.length, 1);
|
||||
t.is(upserts.length, 1);
|
||||
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.subscription.activated',
|
||||
payload: {
|
||||
workspaceId: 'ws',
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -86,7 +86,10 @@ test('should cleanup expired pending blobs', async t => {
|
||||
],
|
||||
});
|
||||
|
||||
const abortSpy = Sinon.spy(t.context.storage, 'abortMultipartUpload');
|
||||
const abortSpy = Sinon.stub(
|
||||
t.context.storage,
|
||||
'abortMultipartUpload'
|
||||
).resolves();
|
||||
const deleteSpy = Sinon.spy(t.context.storage, 'delete');
|
||||
t.teardown(() => {
|
||||
abortSpy.restore();
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TestingApp } from './utils';
|
||||
type TestContext = {
|
||||
app: TestingApp;
|
||||
};
|
||||
const test = ava as TestFn<TestContext>;
|
||||
const test = ava.serial as TestFn<TestContext>;
|
||||
|
||||
let safeFetchStub: Sinon.SinonStub | undefined;
|
||||
let safeFetchHandler:
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createHash } from 'node:crypto';
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Config, StorageProviderFactory } from '../../base';
|
||||
import { Config, ConfigFactory, StorageProviderFactory } from '../../base';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
|
||||
import { BlobModel, WorkspaceFeatureModel } from '../../models';
|
||||
import {
|
||||
@@ -35,6 +36,18 @@ let model: WorkspaceFeatureModel;
|
||||
test.before(async () => {
|
||||
app = await createTestingApp();
|
||||
model = app.get(WorkspaceFeatureModel);
|
||||
app.get(ConfigFactory).override({
|
||||
storages: {
|
||||
blob: {
|
||||
storage: {
|
||||
provider: 'fs',
|
||||
bucket: 'test',
|
||||
config: { path: '/tmp/affine-test-storage' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.get(WorkspaceBlobStorage).onConfigInit();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
@@ -45,6 +58,26 @@ test.after.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function withRestrictedWorkspaceQuota(workspaceId: string) {
|
||||
const quotaState = app.get(QuotaStateService);
|
||||
const blobModel = app.get(BlobModel);
|
||||
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
|
||||
async id => {
|
||||
if (id !== workspaceId) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
|
||||
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
|
||||
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test('should set blobs', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
@@ -233,7 +266,8 @@ test('should reject blob exceeded limit', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
const workspace1 = await createWorkspace(app);
|
||||
await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
|
||||
const quotaStub = await withRestrictedWorkspaceQuota(workspace1.id);
|
||||
t.teardown(() => quotaStub.restore());
|
||||
|
||||
const buffer1 = Buffer.from(
|
||||
Array.from({ length: RESTRICTED_QUOTA.blobLimit + 1 }, () => 0)
|
||||
@@ -247,7 +281,8 @@ test('should reject blob exceeded storage quota', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
const workspace = await createWorkspace(app);
|
||||
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
|
||||
const quotaStub = await withRestrictedWorkspaceQuota(workspace.id);
|
||||
t.teardown(() => quotaStub.restore());
|
||||
|
||||
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import Sinon from 'sinon';
|
||||
import supertest from 'supertest';
|
||||
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
|
||||
|
||||
import { ConfigFactory } from '../../base';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||
import { PermissionReadModel } from '../../core/permission/config';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
|
||||
import {
|
||||
@@ -152,6 +154,31 @@ test('should be able to get private workspace with public pages', async t => {
|
||||
t.is(res.text, 'blob');
|
||||
});
|
||||
|
||||
test('should be able to get private workspace with public pages using new permission model', async t => {
|
||||
const { app, storage } = t.context;
|
||||
const config = app.get(ConfigFactory);
|
||||
|
||||
config.override({
|
||||
permission: {
|
||||
readModel: PermissionReadModel.Projection,
|
||||
},
|
||||
});
|
||||
try {
|
||||
storage.get.resolves(blob());
|
||||
const res = await app.GET('/api/workspaces/private/blobs/test');
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'text/plain');
|
||||
t.is(res.text, 'blob');
|
||||
} finally {
|
||||
config.override({
|
||||
permission: {
|
||||
readModel: PermissionReadModel.Legacy,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should not be able to get private workspace with no public pages', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ import { CacheInterceptor } from './interceptor';
|
||||
})
|
||||
export class CacheModule {}
|
||||
export { Cache, SessionCache };
|
||||
|
||||
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
|
||||
export { isValidCacheTtl } from './provider';
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface CacheSetOptions {
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export function isValidCacheTtl(ttl: unknown): ttl is number {
|
||||
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
|
||||
}
|
||||
|
||||
export class CacheProvider {
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
Cache,
|
||||
CacheInterceptor,
|
||||
isValidCacheTtl,
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
SessionCache,
|
||||
|
||||
@@ -62,6 +62,7 @@ export type KnownMetricScopes =
|
||||
| 'queue'
|
||||
| 'storage'
|
||||
| 'process'
|
||||
| 'permission'
|
||||
| 'workspace';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden } from '../../base';
|
||||
import { ActionForbidden, EventBus } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { UserType } from '../user';
|
||||
@@ -26,7 +26,10 @@ class GenerateAccessTokenInput {
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Query(() => [RevealedAccessToken], {
|
||||
deprecationReason: 'use currentUser.revealedAccessTokens',
|
||||
@@ -42,11 +45,13 @@ export class AccessTokenResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
return await this.models.accessToken.create({
|
||||
const token = await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
this.event.emit('user.access_token.created', { userId: user.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -55,6 +60,7 @@ export class AccessTokenResolver {
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
this.event.emit('user.access_token.revoked', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { decodeWithJson, encodeWithJson } from '../../base/graphql';
|
||||
import { AccessController } from '../permission';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import {
|
||||
realtimeCommentRoom,
|
||||
RealtimePublisher,
|
||||
@@ -20,7 +20,7 @@ export function commentRoom(workspaceId: string, docId: string) {
|
||||
export class CommentRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly registry: RealtimeRegistry
|
||||
) {}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { Comment, DocMode, Models, Reply } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { ServerFeature, ServerService } from '../config';
|
||||
import { AccessController, DocAction } from '../permission';
|
||||
import { DocAction, PermissionAccess } from '../permission';
|
||||
import { RealtimePublisher } from '../realtime';
|
||||
import { CommentAttachmentStorage } from '../storage';
|
||||
import { UserType } from '../user';
|
||||
@@ -54,7 +54,7 @@ export interface CommentCursor {
|
||||
export class CommentResolver {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly commentAttachmentStorage: CommentAttachmentStorage,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly models: Models,
|
||||
@@ -469,11 +469,7 @@ export class CommentResolver {
|
||||
|
||||
private async assertPermission(
|
||||
me: UserType,
|
||||
item: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId?: string;
|
||||
},
|
||||
item: { workspaceId: string; docId: string; userId?: string },
|
||||
action: DocAction
|
||||
) {
|
||||
// the owner of the comment/reply can update, delete, resolve it
|
||||
|
||||
@@ -173,7 +173,7 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
description: 'Workspace features available for admin configuration',
|
||||
})
|
||||
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
|
||||
return ['unlimited_workspace', 'team_plan_v1'];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Models } from '../../models';
|
||||
import { htmlSanitize } from '../../native';
|
||||
import { Public } from '../auth';
|
||||
import { DocReader } from '../doc';
|
||||
import { WorkspacePolicyService } from '../permission';
|
||||
import { PermissionService } from '../permission';
|
||||
|
||||
interface RenderOptions {
|
||||
title: string;
|
||||
@@ -61,7 +61,7 @@ export class DocRendererController {
|
||||
private readonly doc: DocReader,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
private readonly permission: PermissionService
|
||||
) {
|
||||
this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static'));
|
||||
this.mobileAssets = this.readHtmlAssets(
|
||||
@@ -99,10 +99,11 @@ export class DocRendererController {
|
||||
req.accepts().some(t => markdownType.has(t.toLowerCase()))
|
||||
) {
|
||||
try {
|
||||
const canReadMarkdown = await this.policy.canReadSharedDoc(
|
||||
const canReadMarkdown = await this.permission.canDoc({
|
||||
workspaceId,
|
||||
sub
|
||||
);
|
||||
docId: sub,
|
||||
action: 'Doc.Read',
|
||||
});
|
||||
if (!canReadMarkdown) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
@@ -162,7 +163,7 @@ export class DocRendererController {
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
if (await this.policy.canPreviewDoc(workspaceId, docId)) {
|
||||
if (await this.permission.canPreviewDoc({ workspaceId, docId })) {
|
||||
return this.doc.getDocContent(workspaceId, docId);
|
||||
}
|
||||
|
||||
@@ -172,8 +173,9 @@ export class DocRendererController {
|
||||
private async getWorkspaceContent(
|
||||
workspaceId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
const canPreviewWorkspace =
|
||||
await this.policy.canPreviewWorkspace(workspaceId);
|
||||
const canPreviewWorkspace = await this.permission.canPreviewWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
if (!canPreviewWorkspace) return null;
|
||||
|
||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||
|
||||
@@ -73,7 +73,7 @@ export class DocStorageOptions implements IDocStorageOptions {
|
||||
|
||||
historyMaxAge = async (spaceId: string) => {
|
||||
const quota = await this.quota.getWorkspaceQuota(spaceId);
|
||||
return quota.historyPeriod;
|
||||
return quota.historyPeriod * 1000;
|
||||
};
|
||||
|
||||
historyMinInterval = (_spaceId: string) => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import {
|
||||
EntitlementModule,
|
||||
EntitlementProjectionChecker,
|
||||
EntitlementService,
|
||||
} from '../index';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
entitlement: EntitlementService;
|
||||
checker: EntitlementProjectionChecker;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [EntitlementModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
t.context.checker = module.get(EntitlementProjectionChecker);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('checker distinguishes valid projection from dirty legacy features', async t => {
|
||||
const cleanUser = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: cleanUser.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const dirtyUser = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.userFeature.add(
|
||||
dirtyUser.id,
|
||||
'pro_plan_v1',
|
||||
'dirty legacy feature'
|
||||
);
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.dirtyLegacyUserFeatures, 1);
|
||||
t.is(report.missingUserFeatureProjection, 0);
|
||||
});
|
||||
|
||||
test('checker reports missing legacy projection and stale state', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.db.subscription.delete({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
});
|
||||
await t.context.db.effectiveUserQuotaState.update({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
staleAfter: new Date('2020-01-01T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.cloudSubscriptionProjectionMissing, 1);
|
||||
t.is(report.staleEffectiveUserState, 1);
|
||||
});
|
||||
|
||||
test('checker reports legal legacy facts missing entitlements', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'legacy-verifiable-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 5,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
validatedAt: new Date(),
|
||||
license: Buffer.from('raw-license'),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.cloudSubscriptionEntitlementMissing, 1);
|
||||
t.is(report.selfhostLicenseEntitlementMissing, 1);
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { EntitlementModule, EntitlementService } from '../index';
|
||||
import { LegacyEntitlementProjectionService } from '../projection';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
entitlement: EntitlementService;
|
||||
projection: LegacyEntitlementProjectionService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [EntitlementModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
t.context.projection = module.get(LegacyEntitlementProjectionService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('projects user entitlement to legacy user features and subscriptions', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1'));
|
||||
t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
|
||||
t.like(
|
||||
await t.context.db.subscription.findUnique({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
}),
|
||||
{
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
}
|
||||
);
|
||||
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
});
|
||||
t.false(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
|
||||
});
|
||||
|
||||
test('projects workspace entitlement and readonly state to legacy workspace features', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
quantity: 8,
|
||||
});
|
||||
|
||||
const teamFeature = await t.context.models.workspaceFeature.get(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
);
|
||||
t.is(teamFeature?.configs.memberLimit, 8);
|
||||
|
||||
await t.context.db.effectiveWorkspaceQuotaState.upsert({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
create: {
|
||||
workspaceId: workspace.id,
|
||||
plan: 'free',
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 3,
|
||||
memberCount: 4,
|
||||
overcapacityMemberCount: 1,
|
||||
blobLimit: BigInt(10),
|
||||
storageQuota: BigInt(10),
|
||||
usedStorageQuota: BigInt(1),
|
||||
historyPeriodSeconds: 7,
|
||||
readonly: true,
|
||||
readonlyReasons: ['member_overflow'],
|
||||
known: true,
|
||||
stale: false,
|
||||
},
|
||||
update: {
|
||||
plan: 'free',
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 3,
|
||||
memberCount: 4,
|
||||
overcapacityMemberCount: 1,
|
||||
blobLimit: BigInt(10),
|
||||
storageQuota: BigInt(10),
|
||||
usedStorageQuota: BigInt(1),
|
||||
historyPeriodSeconds: 7,
|
||||
readonly: true,
|
||||
readonlyReasons: ['member_overflow'],
|
||||
known: true,
|
||||
stale: false,
|
||||
},
|
||||
});
|
||||
await t.context.projection.onWorkspaceQuotaStateChanged({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('installed license scanner never trusts quantity without raw license', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'legacy-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 100,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: '',
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.scanInstalledLicenses();
|
||||
|
||||
const entitlement = await t.context.db.entitlement.findFirst({
|
||||
where: {
|
||||
source: 'selfhost_license',
|
||||
subjectId: 'legacy-key',
|
||||
},
|
||||
});
|
||||
t.is(entitlement?.status, 'needs_reupload');
|
||||
t.is(entitlement?.quantity, null);
|
||||
});
|
||||
|
||||
test.serial(
|
||||
'selfhosted legacy projection ignores unknown entitlements',
|
||||
async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
try {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'ai',
|
||||
status: 'active',
|
||||
subjectId: `forged-ai:${user.id}`,
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
|
||||
t.false(
|
||||
await t.context.models.userFeature.has(user.id, 'unlimited_copilot')
|
||||
);
|
||||
t.is(
|
||||
await t.context.db.subscription.count({ where: { targetId: user.id } }),
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test('backfill marks selfhost team subscriptions as needing license revalidation', async t => {
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: 'license-key-target',
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.backfillEntitlementsAndQuotaStates();
|
||||
|
||||
t.like(
|
||||
await t.context.db.entitlement.findFirstOrThrow({
|
||||
where: {
|
||||
source: 'selfhost_license',
|
||||
subjectId: 'license-key-target',
|
||||
},
|
||||
}),
|
||||
{
|
||||
targetType: 'instance',
|
||||
targetId: 'license-key-target',
|
||||
plan: 'selfhost_team',
|
||||
status: 'needs_reupload',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('backfill removes dangling legacy subscriptions and entitlements', async t => {
|
||||
await t.context.db.subscription.createMany({
|
||||
data: [
|
||||
{
|
||||
targetId: randomUUID(),
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
{
|
||||
targetId: randomUUID(),
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
await t.context.db.entitlement.createMany({
|
||||
data: [
|
||||
{
|
||||
targetType: 'user',
|
||||
targetId: randomUUID(),
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
subjectId: randomUUID(),
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: randomUUID(),
|
||||
source: 'cloud_subscription',
|
||||
plan: 'team',
|
||||
status: 'active',
|
||||
subjectId: randomUUID(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await t.context.projection.backfillEntitlementsAndQuotaStates();
|
||||
|
||||
t.is(await t.context.db.subscription.count(), 0);
|
||||
t.is(await t.context.db.entitlement.count(), 0);
|
||||
});
|
||||
|
||||
test('key based selfhost entitlements without raw payload need reupload', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: workspace.id,
|
||||
licenseKey: 'remote-key',
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
quantity: 5,
|
||||
validateKey: 'validate-key',
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
});
|
||||
|
||||
await t.context.projection.scanInstalledLicenses();
|
||||
|
||||
t.like(
|
||||
await t.context.db.entitlement.findFirstOrThrow({
|
||||
where: { source: 'selfhost_license', subjectId: 'remote-key' },
|
||||
}),
|
||||
{ status: 'needs_reupload', quantity: null }
|
||||
);
|
||||
});
|
||||
|
||||
test('revoked selfhost entitlement removes installed license projection', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'active',
|
||||
subjectId: 'revoked-key',
|
||||
quantity: 5,
|
||||
signedPayload: Buffer.from('signed-license-payload'),
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'revoked-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 5,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
validatedAt: new Date(),
|
||||
license: Buffer.from('signed-license-payload'),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.entitlement.revokeBySubject(
|
||||
'selfhost_license',
|
||||
'revoked-key'
|
||||
);
|
||||
|
||||
t.falsy(
|
||||
await t.context.db.installedLicense.findUnique({
|
||||
where: { workspaceId: workspace.id },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('installed license projection uses explicit entitlement status priority', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.entitlement.createMany({
|
||||
data: [
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'expired',
|
||||
subjectId: 'expired-key',
|
||||
quantity: 5,
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'expired-validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() - 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'grace',
|
||||
subjectId: 'grace-key',
|
||||
quantity: 6,
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'grace-validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() - 1800_000),
|
||||
graceUntil: new Date(Date.now() + 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
});
|
||||
|
||||
const installedLicense =
|
||||
await t.context.db.installedLicense.findUniqueOrThrow({
|
||||
where: { workspaceId: workspace.id },
|
||||
});
|
||||
t.is(installedLicense.key, 'grace-key');
|
||||
t.is(installedLicense.quantity, 6);
|
||||
t.is(installedLicense.validateKey, 'grace-validate-key');
|
||||
});
|
||||
|
||||
test.serial(
|
||||
'selfhosted projection does not trust non-null signed payload',
|
||||
async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
try {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'active',
|
||||
subjectId: 'forged-key',
|
||||
quantity: 100,
|
||||
signedPayload: Buffer.from('not-a-valid-license'),
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
});
|
||||
|
||||
t.falsy(
|
||||
await t.context.models.workspaceFeature.get(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
)
|
||||
);
|
||||
t.falsy(
|
||||
await t.context.db.installedLicense.findUnique({
|
||||
where: { workspaceId: workspace.id },
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,508 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { EntitlementModule } from '../index';
|
||||
import { EntitlementService } from '../service';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
service: EntitlementService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [EntitlementModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.service = module.get(EntitlementService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('upserts admin grant entitlement as commercial source of truth', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'admin-grant-owner@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
const entitlement = await t.context.service.upsertAdminGrant({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
plan: 'team',
|
||||
quantity: 6,
|
||||
});
|
||||
const resolved = await t.context.service.resolveWorkspaceEntitlement(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.is(entitlement.source, 'admin_grant');
|
||||
t.is(entitlement.plan, 'team');
|
||||
t.is(entitlement.quantity, 6);
|
||||
t.is(resolved.plan, 'team');
|
||||
t.is(resolved.quota.seatLimit, 6);
|
||||
});
|
||||
|
||||
test('admin grant replaces and revokes previous admin grant', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'admin-grant-replace@affine.pro',
|
||||
});
|
||||
|
||||
await t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'lifetime_pro',
|
||||
});
|
||||
await t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
});
|
||||
|
||||
const [resolved, entitlements] = await Promise.all([
|
||||
t.context.service.resolveUserEntitlement(user.id),
|
||||
t.context.db.entitlement.findMany({
|
||||
where: { source: 'admin_grant', targetId: user.id },
|
||||
}),
|
||||
]);
|
||||
|
||||
t.is(resolved.plan, 'pro');
|
||||
t.is(
|
||||
entitlements.filter(entitlement => entitlement.status === 'active').length,
|
||||
1
|
||||
);
|
||||
t.false(
|
||||
entitlements.some(
|
||||
entitlement =>
|
||||
entitlement.plan === 'lifetime_pro' && entitlement.status === 'active'
|
||||
)
|
||||
);
|
||||
|
||||
await t.context.service.revokeAdminGrant('user', user.id);
|
||||
t.is((await t.context.service.resolveUserEntitlement(user.id)).plan, 'free');
|
||||
});
|
||||
|
||||
test('admin grant rejects self-hosted commercial entitlement without writing', async t => {
|
||||
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific entitlement semantics
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'admin-grant-selfhost@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
try {
|
||||
await t.throwsAsync(
|
||||
t.context.service.upsertAdminGrant({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
plan: 'team',
|
||||
quantity: 6,
|
||||
}),
|
||||
{ message: /signed license/ }
|
||||
);
|
||||
t.is(
|
||||
await t.context.db.entitlement.count({
|
||||
where: { source: 'admin_grant', targetId: workspace.id },
|
||||
}),
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = originalDeploymentType;
|
||||
}
|
||||
});
|
||||
|
||||
test('admin grant rejects incompatible target plan without writing', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'admin-grant-invalid@affine.pro',
|
||||
});
|
||||
|
||||
await t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
});
|
||||
await t.throwsAsync(
|
||||
t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'team',
|
||||
quantity: 6,
|
||||
}),
|
||||
{ message: /not configurable/ }
|
||||
);
|
||||
|
||||
const active = await t.context.db.entitlement.findMany({
|
||||
where: { source: 'admin_grant', targetId: user.id, status: 'active' },
|
||||
});
|
||||
t.is(active.length, 1);
|
||||
t.is(active[0].plan, 'pro');
|
||||
});
|
||||
|
||||
test('upserts cloud subscription entitlements without writing legacy features', async t => {
|
||||
const proUser = await t.context.models.user.create({
|
||||
email: 'user-pro@affine.pro',
|
||||
});
|
||||
const aiUser = await t.context.models.user.create({
|
||||
email: 'user-ai@affine.pro',
|
||||
});
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'workspace-owner@affine.pro',
|
||||
});
|
||||
const teamWorkspace = await t.context.models.workspace.create(owner.id);
|
||||
const cases = [
|
||||
{
|
||||
targetId: proUser.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
expected: { targetType: 'user', plan: 'pro', status: 'active' },
|
||||
},
|
||||
{
|
||||
targetId: aiUser.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'trialing',
|
||||
expected: { targetType: 'user', plan: 'ai', status: 'active' },
|
||||
},
|
||||
{
|
||||
targetId: teamWorkspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'past_due',
|
||||
quantity: 7,
|
||||
expected: { targetType: 'workspace', plan: 'team', status: 'grace' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
...item,
|
||||
subscriptionId: `${item.targetId}:${item.plan}`,
|
||||
start: new Date('2026-05-14T00:00:00Z'),
|
||||
});
|
||||
|
||||
t.like(entitlement, item.expected, item.targetId);
|
||||
}
|
||||
|
||||
t.is(await t.context.db.entitlement.count(), cases.length);
|
||||
});
|
||||
|
||||
test('revokes cloud subscription entitlement by subject', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'revoke-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
subscriptionId: 'sub_1',
|
||||
});
|
||||
|
||||
await t.context.service.revokeCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
subscriptionId: 'sub_1',
|
||||
});
|
||||
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
t.is(updated?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('revokes onetime or revenuecat entitlements using fallback subject', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'fallback-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await t.context.service.revokeCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
subscriptionId: 1,
|
||||
});
|
||||
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
t.is(updated?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('resolves higher priority commercial entitlement over ai capability', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'priority-user@affine.pro',
|
||||
});
|
||||
await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
t.is(resolved.plan, 'pro');
|
||||
t.is(resolved.quota.storageQuota, 100 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('ignores expired active entitlements during best entitlement selection', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'expired-user@affine.pro',
|
||||
});
|
||||
const cases = [
|
||||
{
|
||||
status: 'active',
|
||||
subjectId: 'expired-subscription',
|
||||
expiresAt: new Date('2020-01-01T00:00:00Z'),
|
||||
},
|
||||
{
|
||||
status: 'grace',
|
||||
subjectId: 'open-ended-grace',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
...item,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
t.is(resolved.plan, 'free');
|
||||
});
|
||||
|
||||
test('selfhosted resolution ignores unsigned DB entitlements', async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific trust boundary
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
try {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'forged-user@affine.pro',
|
||||
});
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'forged-workspace-owner@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
const cases = [
|
||||
{
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'ai',
|
||||
quantity: null,
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'team',
|
||||
quantity: 100,
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
quantity: 100,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const item of cases) {
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
...item,
|
||||
status: 'active',
|
||||
subjectId: `${item.source}:${item.plan}:${item.targetId}`,
|
||||
quantity: item.quantity ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
|
||||
t.falsy(
|
||||
await t.context.service.getBestEntitlement('workspace', workspace.id)
|
||||
);
|
||||
|
||||
const userResolved = await t.context.service.resolveUserEntitlement(
|
||||
user.id
|
||||
);
|
||||
const workspaceResolved =
|
||||
await t.context.service.resolveWorkspaceEntitlement(workspace.id);
|
||||
|
||||
t.is(userResolved.plan, 'selfhost_free');
|
||||
t.is(workspaceResolved.plan, 'selfhost_free');
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
});
|
||||
|
||||
test('cloud resolution lazily imports legacy subscriptions written after backfill', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'legacy-subscription-user@affine.pro',
|
||||
});
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
quantity: 1,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const userResolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
const userEntitlement = await t.context.db.entitlement.findFirst({
|
||||
where: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
t.is(userResolved.plan, 'pro');
|
||||
t.is(userEntitlement?.status, 'active');
|
||||
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'legacy-subscription-owner@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
quantity: 7,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const workspaceResolved = await t.context.service.resolveWorkspaceEntitlement(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.is(workspaceResolved.plan, 'team');
|
||||
t.is(workspaceResolved.quantity, 7);
|
||||
t.is(workspaceResolved.quota.seatLimit, 7);
|
||||
|
||||
await t.context.db.subscription.delete({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
|
||||
const revokedResolved = await t.context.service.resolveUserEntitlement(
|
||||
user.id
|
||||
);
|
||||
const revokedEntitlement = await t.context.db.entitlement.findFirst({
|
||||
where: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
t.is(revokedResolved.plan, 'free');
|
||||
t.is(revokedEntitlement?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('cloud resolution revokes projected entitlements after legacy subscription deletion', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'legacy-delete-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
|
||||
await t.context.db.subscription.findUniqueOrThrow({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
await t.context.db.subscription.delete({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
|
||||
t.is(resolved.plan, 'free');
|
||||
t.is(updated?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('cloud resolution keeps projected string-subscription entitlements while legacy row exists', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'string-subscription-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
subscriptionId: 'sub_legacy_string',
|
||||
});
|
||||
|
||||
await t.context.db.subscription.findUniqueOrThrow({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
|
||||
t.is(resolved.plan, 'pro');
|
||||
t.is(updated?.status, 'active');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LegacyEntitlementProjectionService } from './projection';
|
||||
import { EntitlementProjectionChecker } from './projection-checker';
|
||||
import { EntitlementService } from './service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
EntitlementService,
|
||||
LegacyEntitlementProjectionService,
|
||||
EntitlementProjectionChecker,
|
||||
],
|
||||
exports: [
|
||||
EntitlementService,
|
||||
LegacyEntitlementProjectionService,
|
||||
EntitlementProjectionChecker,
|
||||
],
|
||||
})
|
||||
export class EntitlementModule {}
|
||||
|
||||
export { EntitlementService };
|
||||
export { EntitlementProjectionChecker };
|
||||
export { LegacyEntitlementProjectionService };
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class EntitlementProjectionChecker {
|
||||
constructor(private readonly db: PrismaClient) {}
|
||||
|
||||
async checkEntitlementProjection() {
|
||||
const now = new Date();
|
||||
const [
|
||||
missingEffectiveUserState,
|
||||
missingEffectiveWorkspaceState,
|
||||
staleEffectiveUserState,
|
||||
staleEffectiveWorkspaceState,
|
||||
cloudSubscriptionProjectionMissing,
|
||||
selfhostLicenseProjectionMissing,
|
||||
cloudSubscriptionEntitlementMissing,
|
||||
selfhostLicenseEntitlementMissing,
|
||||
dirtyLegacyUserFeatures,
|
||||
dirtyLegacyWorkspaceFeatures,
|
||||
missingUserFeatureProjection,
|
||||
missingWorkspaceFeatureProjection,
|
||||
] = await Promise.all([
|
||||
this.db.user.count({
|
||||
where: { quotaState: null },
|
||||
}),
|
||||
this.db.workspace.count({
|
||||
where: { quotaState: null },
|
||||
}),
|
||||
this.db.effectiveUserQuotaState.count({
|
||||
where: {
|
||||
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
|
||||
},
|
||||
}),
|
||||
this.db.effectiveWorkspaceQuotaState.count({
|
||||
where: {
|
||||
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
|
||||
},
|
||||
}),
|
||||
this.cloudSubscriptionProjectionMissing(),
|
||||
this.selfhostLicenseProjectionMissing(),
|
||||
this.cloudSubscriptionEntitlementMissing(),
|
||||
this.selfhostLicenseEntitlementMissing(),
|
||||
this.dirtyLegacyUserFeatures(),
|
||||
this.dirtyLegacyWorkspaceFeatures(),
|
||||
this.missingUserFeatureProjection(),
|
||||
this.missingWorkspaceFeatureProjection(),
|
||||
]);
|
||||
|
||||
return {
|
||||
missingEffectiveUserState,
|
||||
missingEffectiveWorkspaceState,
|
||||
staleEffectiveUserState,
|
||||
staleEffectiveWorkspaceState,
|
||||
cloudSubscriptionProjectionMissing,
|
||||
selfhostLicenseProjectionMissing,
|
||||
cloudSubscriptionEntitlementMissing,
|
||||
selfhostLicenseEntitlementMissing,
|
||||
dirtyLegacyUserFeatures,
|
||||
dirtyLegacyWorkspaceFeatures,
|
||||
missingUserFeatureProjection,
|
||||
missingWorkspaceFeatureProjection,
|
||||
};
|
||||
}
|
||||
|
||||
private async cloudSubscriptionProjectionMissing() {
|
||||
const legacyKeys = new Set(
|
||||
(
|
||||
await this.db.subscription.findMany({
|
||||
where: {
|
||||
status: { in: ['active', 'trialing', 'past_due'] },
|
||||
},
|
||||
select: { targetId: true, plan: true },
|
||||
})
|
||||
).map(subscription => `${subscription.targetId}:${subscription.plan}`)
|
||||
);
|
||||
const entitlements = await this.validEntitlements({
|
||||
source: 'cloud_subscription',
|
||||
});
|
||||
|
||||
return entitlements.filter(
|
||||
entitlement =>
|
||||
entitlement.targetId &&
|
||||
!legacyKeys.has(
|
||||
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
|
||||
)
|
||||
).length;
|
||||
}
|
||||
|
||||
private async selfhostLicenseProjectionMissing() {
|
||||
const licenseKeys = new Set(
|
||||
(
|
||||
await this.db.installedLicense.findMany({
|
||||
select: { key: true },
|
||||
})
|
||||
).map(license => license.key)
|
||||
);
|
||||
const entitlements = await this.validEntitlements({
|
||||
source: 'selfhost_license',
|
||||
});
|
||||
|
||||
return entitlements.filter(
|
||||
entitlement =>
|
||||
entitlement.subjectId && !licenseKeys.has(entitlement.subjectId)
|
||||
).length;
|
||||
}
|
||||
|
||||
private async cloudSubscriptionEntitlementMissing() {
|
||||
const activeSubscriptions = await this.db.subscription.findMany({
|
||||
where: {
|
||||
status: { in: ['active', 'trialing', 'past_due'] },
|
||||
},
|
||||
select: { targetId: true, plan: true },
|
||||
});
|
||||
const valid = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
source: 'cloud_subscription',
|
||||
})
|
||||
).map(
|
||||
entitlement =>
|
||||
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
|
||||
)
|
||||
);
|
||||
|
||||
return activeSubscriptions.filter(
|
||||
subscription =>
|
||||
!valid.has(`${subscription.targetId}:${subscription.plan}`)
|
||||
).length;
|
||||
}
|
||||
|
||||
private async selfhostLicenseEntitlementMissing() {
|
||||
const licenses = await this.db.installedLicense.findMany({
|
||||
where: {
|
||||
license: { not: null },
|
||||
},
|
||||
select: { key: true },
|
||||
});
|
||||
const validKeys = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
source: 'selfhost_license',
|
||||
})
|
||||
).flatMap(entitlement => entitlement.subjectId ?? [])
|
||||
);
|
||||
|
||||
return licenses.filter(license => !validKeys.has(license.key)).length;
|
||||
}
|
||||
|
||||
private async dirtyLegacyUserFeatures() {
|
||||
const rows = await this.db.userFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: {
|
||||
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const valid = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
targetType: 'user',
|
||||
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
|
||||
})
|
||||
).map(entitlement => `${entitlement.targetId}:${entitlement.plan}`)
|
||||
);
|
||||
|
||||
return rows.filter(row => {
|
||||
const plan =
|
||||
row.name === 'lifetime_pro_plan_v1'
|
||||
? 'lifetime_pro'
|
||||
: row.name === 'pro_plan_v1'
|
||||
? 'pro'
|
||||
: 'ai';
|
||||
return !valid.has(`${row.userId}:${plan}`);
|
||||
}).length;
|
||||
}
|
||||
|
||||
private async dirtyLegacyWorkspaceFeatures() {
|
||||
const rows = await this.db.workspaceFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: 'team_plan_v1',
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
const validWorkspaceIds = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
targetType: 'workspace',
|
||||
plan: { in: ['team', 'selfhost_team'] },
|
||||
})
|
||||
).flatMap(entitlement => entitlement.targetId ?? [])
|
||||
);
|
||||
|
||||
return rows.filter(row => !validWorkspaceIds.has(row.workspaceId)).length;
|
||||
}
|
||||
|
||||
private async missingUserFeatureProjection() {
|
||||
const entitlements = await this.validEntitlements({
|
||||
targetType: 'user',
|
||||
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
|
||||
});
|
||||
const features = new Set(
|
||||
(
|
||||
await this.db.userFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: {
|
||||
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
|
||||
},
|
||||
},
|
||||
select: { userId: true, name: true },
|
||||
})
|
||||
).map(feature => `${feature.userId}:${feature.name}`)
|
||||
);
|
||||
|
||||
return entitlements.filter(entitlement => {
|
||||
if (!entitlement.targetId) {
|
||||
return false;
|
||||
}
|
||||
const feature =
|
||||
entitlement.plan === 'lifetime_pro'
|
||||
? 'lifetime_pro_plan_v1'
|
||||
: entitlement.plan === 'pro'
|
||||
? 'pro_plan_v1'
|
||||
: 'unlimited_copilot';
|
||||
return !features.has(`${entitlement.targetId}:${feature}`);
|
||||
}).length;
|
||||
}
|
||||
|
||||
private async missingWorkspaceFeatureProjection() {
|
||||
const entitlements = await this.validEntitlements({
|
||||
targetType: 'workspace',
|
||||
plan: { in: ['team', 'selfhost_team'] },
|
||||
});
|
||||
const featureWorkspaceIds = new Set(
|
||||
(
|
||||
await this.db.workspaceFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: 'team_plan_v1',
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
})
|
||||
).map(feature => feature.workspaceId)
|
||||
);
|
||||
|
||||
return entitlements.filter(
|
||||
entitlement =>
|
||||
entitlement.targetId && !featureWorkspaceIds.has(entitlement.targetId)
|
||||
).length;
|
||||
}
|
||||
|
||||
private validEntitlements(where: Record<string, unknown>) {
|
||||
const now = new Date();
|
||||
return this.db.entitlement.findMany({
|
||||
where: {
|
||||
...where,
|
||||
...(where.source === 'selfhost_license'
|
||||
? { signedPayload: { not: null } }
|
||||
: {}),
|
||||
OR: [
|
||||
{
|
||||
status: 'active',
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
{
|
||||
status: 'grace',
|
||||
graceUntil: { gt: now },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
targetId: true,
|
||||
subjectId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private subscriptionPlan(plan: string) {
|
||||
return plan === 'lifetime_pro' ? 'pro' : plan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Entitlement, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { OnEvent } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../plugins/payment/types';
|
||||
import { EntitlementService } from './service';
|
||||
|
||||
type Metadata = {
|
||||
provider?: string | null;
|
||||
recurring?: string | null;
|
||||
variant?: string | null;
|
||||
subscriptionId?: string | number | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
validateKey?: string | null;
|
||||
legacyProjected?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LegacyEntitlementProjectionService {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly models: Models,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {}
|
||||
|
||||
@OnEvent('entitlement.changed')
|
||||
async onEntitlementChanged({
|
||||
targetType,
|
||||
targetId,
|
||||
}: Events['entitlement.changed']) {
|
||||
if (targetType === 'user') {
|
||||
await this.#projectCloudSubscriptions('user', targetId);
|
||||
await this.#projectUserFeatures(targetId);
|
||||
} else if (targetType === 'workspace') {
|
||||
await this.#projectCloudSubscriptions('workspace', targetId);
|
||||
await Promise.all([
|
||||
this.#projectWorkspaceFeatures(targetId),
|
||||
this.#projectInstalledLicense(targetId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('workspace.quota_state.changed')
|
||||
async onWorkspaceQuotaStateChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.quota_state.changed']) {
|
||||
await this.#projectReadonlyFeature(workspaceId);
|
||||
}
|
||||
|
||||
async scanInstalledLicenses() {
|
||||
const licenses = await this.db.installedLicense.findMany();
|
||||
|
||||
await Promise.all(
|
||||
licenses.map(async license =>
|
||||
license.license
|
||||
? await this.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: license.recurring,
|
||||
quantity: license.quantity,
|
||||
expiresAt: license.expiredAt,
|
||||
validatedAt: license.validatedAt,
|
||||
license: Buffer.from(license.license),
|
||||
})
|
||||
: license.validateKey
|
||||
? await this.entitlement.upsertFromValidatedSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: license.recurring,
|
||||
quantity: license.quantity,
|
||||
expiresAt: license.expiredAt,
|
||||
validatedAt: license.validatedAt,
|
||||
validateKey: license.validateKey,
|
||||
variant: license.variant,
|
||||
})
|
||||
: await this.entitlement.markSelfhostLicenseNeedsReupload({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
reason: 'Installed license has no raw payload to verify.',
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async backfillEntitlementsAndQuotaStates() {
|
||||
await this.#cleanupDanglingLegacyEntitlements();
|
||||
|
||||
const [subscriptions, users, workspaces] = await Promise.all([
|
||||
this.db.subscription.findMany(),
|
||||
this.db.user.findMany({ select: { id: true } }),
|
||||
this.db.workspace.findMany({ select: { id: true } }),
|
||||
]);
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
if (!(await this.#subscriptionTargetExists(subscription))) {
|
||||
continue;
|
||||
}
|
||||
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
|
||||
await this.entitlement.markSelfhostLicenseNeedsReupload({
|
||||
licenseKey: subscription.targetId,
|
||||
reason:
|
||||
'Historical self-hosted team subscription needs license activation or revalidation.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription);
|
||||
}
|
||||
|
||||
await this.scanInstalledLicenses();
|
||||
|
||||
await Promise.all([
|
||||
...users.map(user =>
|
||||
this.db.effectiveUserQuotaState.upsert({
|
||||
where: { userId: user.id },
|
||||
update: { stale: true },
|
||||
create: {
|
||||
userId: user.id,
|
||||
plan: 'free',
|
||||
blobLimit: BigInt(0),
|
||||
storageQuota: BigInt(0),
|
||||
usedStorageQuota: BigInt(0),
|
||||
historyPeriodSeconds: 0,
|
||||
known: false,
|
||||
stale: true,
|
||||
},
|
||||
})
|
||||
),
|
||||
...workspaces.map(workspace =>
|
||||
this.db.effectiveWorkspaceQuotaState.upsert({
|
||||
where: { workspaceId: workspace.id },
|
||||
update: { stale: true },
|
||||
create: {
|
||||
workspaceId: workspace.id,
|
||||
plan: 'free',
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 0,
|
||||
memberCount: 0,
|
||||
overcapacityMemberCount: 0,
|
||||
blobLimit: BigInt(0),
|
||||
storageQuota: BigInt(0),
|
||||
usedStorageQuota: BigInt(0),
|
||||
historyPeriodSeconds: 0,
|
||||
known: false,
|
||||
stale: true,
|
||||
},
|
||||
})
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async #cleanupDanglingLegacyEntitlements() {
|
||||
await this.db.$executeRaw`
|
||||
DELETE FROM entitlements entitlement
|
||||
WHERE (
|
||||
entitlement.target_type = 'user'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id = entitlement.target_id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
entitlement.target_type = 'workspace'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM workspaces
|
||||
WHERE workspaces.id = entitlement.target_id
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
await this.db.$executeRaw`
|
||||
DELETE FROM subscriptions subscription
|
||||
WHERE (
|
||||
subscription.plan IN (${SubscriptionPlan.Pro}, ${SubscriptionPlan.AI})
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id = subscription.target_id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
subscription.plan = ${SubscriptionPlan.Team}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM workspaces
|
||||
WHERE workspaces.id = subscription.target_id
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
async #subscriptionTargetExists(subscription: {
|
||||
targetId: string;
|
||||
plan: string;
|
||||
}) {
|
||||
if (
|
||||
subscription.plan === SubscriptionPlan.Pro ||
|
||||
subscription.plan === SubscriptionPlan.AI
|
||||
) {
|
||||
return !!(await this.db.user.findUnique({
|
||||
where: { id: subscription.targetId },
|
||||
select: { id: true },
|
||||
}));
|
||||
}
|
||||
|
||||
if (subscription.plan === SubscriptionPlan.Team) {
|
||||
return !!(await this.db.workspace.findUnique({
|
||||
where: { id: subscription.targetId },
|
||||
select: { id: true },
|
||||
}));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #projectUserFeatures(userId: string) {
|
||||
const entitlements = await this.#activeEntitlements('user', userId);
|
||||
const quotaEntitlement = entitlements.find(entitlement =>
|
||||
['lifetime_pro', 'pro'].includes(entitlement.plan)
|
||||
);
|
||||
|
||||
if (quotaEntitlement?.plan === 'lifetime_pro') {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'lifetime_pro_plan_v1',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
} else if (quotaEntitlement?.plan === 'pro') {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'pro_plan_v1',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
} else if (
|
||||
await this.hasActiveUserFeature(userId, [
|
||||
'pro_plan_v1',
|
||||
'lifetime_pro_plan_v1',
|
||||
])
|
||||
) {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'free_plan_v1',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
}
|
||||
|
||||
if (entitlements.some(entitlement => entitlement.plan === 'ai')) {
|
||||
await this.models.userFeature.add(
|
||||
userId,
|
||||
'unlimited_copilot',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
} else {
|
||||
await this.models.userFeature.remove(userId, 'unlimited_copilot');
|
||||
}
|
||||
}
|
||||
|
||||
async #projectWorkspaceFeatures(workspaceId: string) {
|
||||
const [entitlement, resolved] = await Promise.all([
|
||||
this.entitlement.getBestEntitlement('workspace', workspaceId),
|
||||
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
||||
]);
|
||||
|
||||
if (
|
||||
entitlement &&
|
||||
['team', 'selfhost_team'].includes(resolved.plan) &&
|
||||
resolved.valid &&
|
||||
resolved.quota.seatLimit
|
||||
) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
'team_plan_v1',
|
||||
'legacy entitlement projection',
|
||||
{
|
||||
memberLimit: resolved.quota.seatLimit,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
||||
}
|
||||
}
|
||||
|
||||
async #projectCloudSubscriptions(
|
||||
targetType: 'user' | 'workspace',
|
||||
targetId: string
|
||||
) {
|
||||
if (env.selfhosted) return;
|
||||
const entitlements = await this.db.entitlement.findMany({
|
||||
where: {
|
||||
targetType,
|
||||
targetId,
|
||||
source: 'cloud_subscription',
|
||||
},
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
});
|
||||
|
||||
for (const entitlement of this.#projectableCloudEntitlements(
|
||||
entitlements
|
||||
)) {
|
||||
const metadata = entitlement.metadata as Metadata;
|
||||
await this.db.subscription.upsert({
|
||||
where: {
|
||||
targetId_plan: {
|
||||
targetId,
|
||||
plan: this.#subscriptionPlan(entitlement.plan),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
||||
provider: this.#provider(metadata.provider),
|
||||
status: this.#subscriptionStatus(entitlement.status),
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialEnd: entitlement.graceUntil,
|
||||
},
|
||||
create: {
|
||||
targetId,
|
||||
plan: this.#subscriptionPlan(entitlement.plan),
|
||||
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
||||
provider: this.#provider(metadata.provider),
|
||||
status: this.#subscriptionStatus(entitlement.status),
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialEnd: entitlement.graceUntil,
|
||||
},
|
||||
});
|
||||
if (!metadata.legacyProjected) {
|
||||
await this.db.entitlement.update({
|
||||
where: { id: entitlement.id },
|
||||
data: {
|
||||
metadata: {
|
||||
...metadata,
|
||||
legacyProjected: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*#projectableCloudEntitlements(entitlements: Entitlement[]) {
|
||||
const byPlan = new Map<string, Entitlement>();
|
||||
|
||||
for (const entitlement of entitlements) {
|
||||
const plan = this.#subscriptionPlan(entitlement.plan);
|
||||
const current = byPlan.get(plan);
|
||||
|
||||
if (
|
||||
!current ||
|
||||
this.#subscriptionProjectionPriority(entitlement) >
|
||||
this.#subscriptionProjectionPriority(current)
|
||||
) {
|
||||
byPlan.set(plan, entitlement);
|
||||
}
|
||||
}
|
||||
|
||||
yield* byPlan.values();
|
||||
}
|
||||
|
||||
#subscriptionProjectionPriority(entitlement: {
|
||||
status: string;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
const statusPriority =
|
||||
entitlement.status === 'active' || entitlement.status === 'grace'
|
||||
? 2
|
||||
: entitlement.status === 'expired'
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
return (
|
||||
statusPriority * 10_000_000_000_000 + entitlement.updatedAt.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
async #projectInstalledLicense(workspaceId: string) {
|
||||
const [entitlements, resolved] = await Promise.all([
|
||||
this.db.entitlement.findMany({
|
||||
where: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
source: 'selfhost_license',
|
||||
},
|
||||
orderBy: [{ signedPayload: 'desc' }, { updatedAt: 'desc' }],
|
||||
}),
|
||||
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
||||
]);
|
||||
const entitlement = entitlements.sort(
|
||||
(left, right) =>
|
||||
this.#installedLicenseStatusPriority(right.status) -
|
||||
this.#installedLicenseStatusPriority(left.status) ||
|
||||
Number(!!right.signedPayload) - Number(!!left.signedPayload) ||
|
||||
right.updatedAt.getTime() - left.updatedAt.getTime()
|
||||
)[0];
|
||||
|
||||
if (!entitlement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolved.plan !== 'selfhost_team' ||
|
||||
!['active', 'grace', 'expired'].includes(resolved.status)
|
||||
) {
|
||||
await this.db.installedLicense.deleteMany({
|
||||
where: { workspaceId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = entitlement.metadata as Metadata;
|
||||
const expiredAt = resolved.expiresAt
|
||||
? new Date(resolved.expiresAt)
|
||||
: entitlement.expiresAt;
|
||||
await this.db.installedLicense.upsert({
|
||||
where: { workspaceId },
|
||||
update: {
|
||||
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
|
||||
quantity: resolved.quantity ?? 1,
|
||||
recurring:
|
||||
resolved.recurring ??
|
||||
metadata.recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
validateKey: metadata.validateKey ?? '',
|
||||
validatedAt: entitlement.validatedAt ?? new Date(),
|
||||
expiredAt,
|
||||
license: entitlement.signedPayload
|
||||
? Buffer.from(entitlement.signedPayload)
|
||||
: null,
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
|
||||
quantity: resolved.quantity ?? 1,
|
||||
recurring:
|
||||
resolved.recurring ??
|
||||
metadata.recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
validateKey: metadata.validateKey ?? '',
|
||||
validatedAt: entitlement.validatedAt ?? new Date(),
|
||||
expiredAt,
|
||||
license: entitlement.signedPayload
|
||||
? Buffer.from(entitlement.signedPayload)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#installedLicenseStatusPriority(status: string) {
|
||||
if (status === 'active' || status === 'grace') {
|
||||
return 3;
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return 2;
|
||||
}
|
||||
if (status === 'needs_reupload') {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async #projectReadonlyFeature(workspaceId: string) {
|
||||
const state = await this.db.effectiveWorkspaceQuotaState.findUnique({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (state?.readonly) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
'quota_exceeded_readonly_workspace_v1',
|
||||
`legacy quota state projection: ${state.readonlyReasons.join(',')}`
|
||||
);
|
||||
} else {
|
||||
await this.models.workspaceFeature.remove(
|
||||
workspaceId,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #activeEntitlements(
|
||||
targetType: 'user' | 'workspace',
|
||||
targetId: string
|
||||
) {
|
||||
return this.entitlement.getActiveEntitlements(targetType, targetId);
|
||||
}
|
||||
|
||||
private async hasActiveUserFeature(userId: string, names: string[]) {
|
||||
const count = await this.db.userFeature.count({
|
||||
where: {
|
||||
userId,
|
||||
name: { in: names },
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
#subscriptionPlan(plan: string) {
|
||||
if (plan === 'lifetime_pro') {
|
||||
return SubscriptionPlan.Pro;
|
||||
}
|
||||
if (plan === 'selfhost_team') {
|
||||
return SubscriptionPlan.SelfHostedTeam;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
#subscriptionStatus(status: string) {
|
||||
if (status === 'active') {
|
||||
return SubscriptionStatus.Active;
|
||||
}
|
||||
if (status === 'grace') {
|
||||
return SubscriptionStatus.PastDue;
|
||||
}
|
||||
return SubscriptionStatus.Canceled;
|
||||
}
|
||||
|
||||
#provider(provider: string | null | undefined) {
|
||||
return provider === 'revenuecat' ? 'revenuecat' : 'stripe';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { EntitlementModule } from '../entitlement';
|
||||
import {
|
||||
AdminFeatureManagementResolver,
|
||||
UserFeatureResolver,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
import { EarlyAccessType, FeatureService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [EntitlementModule],
|
||||
providers: [
|
||||
UserFeatureResolver,
|
||||
AdminFeatureManagementResolver,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
registerEnumType,
|
||||
@@ -8,13 +9,10 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { difference } from 'lodash-es';
|
||||
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
type UserFeatureName,
|
||||
type WorkspaceFeatureName,
|
||||
} from '../../models';
|
||||
import { BadRequest, EventBus } from '../../base';
|
||||
import { Feature, Models, type UserFeatureName } from '../../models';
|
||||
import { Admin } from '../common';
|
||||
import { EntitlementService } from '../entitlement';
|
||||
import { UserType } from '../user/types';
|
||||
import { AvailableUserFeatureConfig } from './types';
|
||||
|
||||
@@ -42,7 +40,11 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig {
|
||||
@Admin()
|
||||
@Resolver(() => Boolean)
|
||||
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
constructor(private readonly models: Models) {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly event: EventBus
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -55,44 +57,58 @@ export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
features: UserFeatureName[]
|
||||
) {
|
||||
const configurableUserFeatures = this.configurableUserFeatures();
|
||||
const unsupported = features.filter(
|
||||
feature => !configurableUserFeatures.has(feature)
|
||||
);
|
||||
if (unsupported.length) {
|
||||
throw new BadRequest(
|
||||
`User feature ${unsupported.join(', ')} is not configurable`
|
||||
);
|
||||
}
|
||||
const removed = difference(Array.from(configurableUserFeatures), features);
|
||||
|
||||
await Promise.all(
|
||||
features.map(async feature => {
|
||||
if (configurableUserFeatures.has(feature)) {
|
||||
return this.models.userFeature.add(id, feature, 'admin panel');
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
})
|
||||
features.map(feature =>
|
||||
this.models.userFeature.add(id, feature, 'admin panel')
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
removed.map(feature => this.models.userFeature.remove(id, feature))
|
||||
);
|
||||
|
||||
const user = await this.models.user.get(id);
|
||||
if (user) {
|
||||
this.event.emit('user.updated', user);
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async addWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
|
||||
async grantCommercialEntitlement(
|
||||
@Args('targetType', { type: () => String })
|
||||
targetType: 'user' | 'workspace',
|
||||
@Args('targetId', { type: () => String }) targetId: string,
|
||||
@Args('plan', { type: () => String }) plan: string,
|
||||
@Args('quantity', { type: () => Int, nullable: true }) quantity?: number
|
||||
) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
feature,
|
||||
'by administrator'
|
||||
);
|
||||
await this.entitlement.upsertAdminGrant({
|
||||
targetType,
|
||||
targetId,
|
||||
plan,
|
||||
quantity,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async removeWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
|
||||
async revokeCommercialEntitlement(
|
||||
@Args('targetType', { type: () => String })
|
||||
targetType: 'user' | 'workspace',
|
||||
@Args('targetId', { type: () => String }) targetId: string
|
||||
) {
|
||||
await this.models.workspaceFeature.remove(workspaceId, feature);
|
||||
await this.entitlement.revokeAdminGrant(targetType, targetId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,14 @@ import { Feature, UserFeatureName } from '../../models';
|
||||
@Injectable()
|
||||
export class AvailableUserFeatureConfig {
|
||||
availableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set([
|
||||
Feature.Admin,
|
||||
Feature.UnlimitedCopilot,
|
||||
Feature.EarlyAccess,
|
||||
Feature.AIEarlyAccess,
|
||||
]);
|
||||
return new Set([Feature.Admin, Feature.EarlyAccess, Feature.AIEarlyAccess]);
|
||||
}
|
||||
|
||||
configurableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set(
|
||||
env.selfhosted
|
||||
? [Feature.Admin, Feature.UnlimitedCopilot]
|
||||
: [
|
||||
Feature.EarlyAccess,
|
||||
Feature.AIEarlyAccess,
|
||||
Feature.Admin,
|
||||
Feature.UnlimitedCopilot,
|
||||
]
|
||||
? [Feature.Admin]
|
||||
: [Feature.EarlyAccess, Feature.AIEarlyAccess, Feature.Admin]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { paginate, PaginationInput } from '../../base/graphql';
|
||||
import { MentionNotificationCreateSchema } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { AccessController } from '../permission';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import { UserType } from '../user';
|
||||
import { NotificationService } from './service';
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
export class UserNotificationResolver {
|
||||
constructor(
|
||||
private readonly service: NotificationService,
|
||||
private readonly ac: AccessController
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
@ResolveField(() => PaginatedNotificationObjectType, {
|
||||
|
||||
@@ -229,6 +229,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': false,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': false,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': false,
|
||||
@@ -251,6 +252,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': false,
|
||||
@@ -273,6 +275,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -295,6 +298,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -317,6 +321,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Comments.Update': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -339,6 +344,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Comments.Update': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -361,6 +367,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Comments.Update': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -412,6 +419,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': 'Editor',
|
||||
'Doc.Comments.Read': 'External',
|
||||
'Doc.Comments.Resolve': 'Editor',
|
||||
'Doc.Comments.Update': 'Editor',
|
||||
'Doc.Copy': 'External',
|
||||
'Doc.Delete': 'Editor',
|
||||
'Doc.Duplicate': 'Reader',
|
||||
|
||||
BIN
Binary file not shown.
@@ -10,14 +10,13 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { DocAccessController } from '../doc';
|
||||
import { PermissionModule } from '../index';
|
||||
import { PermissionAccess, PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
import { DocRole, mapDocRoleToPermissions } from '../types';
|
||||
|
||||
let module: TestingModule;
|
||||
let models: Models;
|
||||
let ac: DocAccessController;
|
||||
let ac: PermissionAccess;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
@@ -26,7 +25,7 @@ let underReviewUserId: string;
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
models = module.get<Models>(Models);
|
||||
ac = module.get(DocAccessController);
|
||||
ac = module.get(PermissionAccess);
|
||||
policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
|
||||
@@ -40,6 +39,21 @@ test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
function doc(resource: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const checker = ac
|
||||
.user(resource.userId)
|
||||
.doc(resource.workspaceId, resource.docId);
|
||||
if (resource.allowLocal) {
|
||||
checker.allowLocal();
|
||||
}
|
||||
return checker;
|
||||
}
|
||||
|
||||
const roleCases: Array<{
|
||||
title: string;
|
||||
setup?: () => Promise<void>;
|
||||
@@ -90,7 +104,7 @@ const roleCases: Array<{
|
||||
expectedRole: DocRole.Owner,
|
||||
},
|
||||
{
|
||||
title: 'should fallback to [External] if workspace is public',
|
||||
title: 'should not grant private doc role if workspace is public',
|
||||
setup: async () => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
@@ -101,7 +115,7 @@ const roleCases: Array<{
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: DocRole.External,
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title: 'should return null even if workspace has other public doc',
|
||||
@@ -131,9 +145,13 @@ const roleCases: Array<{
|
||||
title: 'should return null if doc role is [None]',
|
||||
setup: async () => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
const u2 = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
underReviewUserId = u2.id;
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
user.id,
|
||||
underReviewUserId,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
@@ -143,7 +161,7 @@ const roleCases: Array<{
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
userId: underReviewUserId,
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
@@ -151,14 +169,6 @@ const roleCases: Array<{
|
||||
title: 'should return [External] if doc role is [None] but doc is public',
|
||||
setup: async () => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
user.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
},
|
||||
resource: () => ({
|
||||
@@ -174,18 +184,18 @@ for (const roleCase of roleCases) {
|
||||
test(roleCase.title, async t => {
|
||||
await roleCase.setup?.();
|
||||
const resource = roleCase.resource();
|
||||
const role = await ac.getRole(resource);
|
||||
const role = (await doc(resource).permissions()).role;
|
||||
|
||||
t.is(role, roleCase.expectedRole);
|
||||
});
|
||||
}
|
||||
|
||||
test('should return mapped permissions', async t => {
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner));
|
||||
});
|
||||
@@ -195,11 +205,11 @@ test('should deny publish permission when workspace sharing is disabled', async
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.false(permissions['Doc.Publish']);
|
||||
t.true(permissions['Doc.Read']);
|
||||
@@ -211,24 +221,18 @@ test('should deny publish assert when workspace sharing is disabled', async t =>
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Publish'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
}).assert('Doc.Publish')
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Read'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
}).assert('Doc.Read')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,34 +243,27 @@ test('should deny external read assert when sharing is disabled even if doc is p
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Doc.Read'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
}).assert('Doc.Read')
|
||||
);
|
||||
});
|
||||
|
||||
test('should assert action', async t => {
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Update'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
}).assert('Doc.Update')
|
||||
);
|
||||
|
||||
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
|
||||
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
|
||||
'Doc.Update'
|
||||
)
|
||||
);
|
||||
@@ -278,8 +275,7 @@ test('should assert action', async t => {
|
||||
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);
|
||||
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
|
||||
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
|
||||
'Doc.Delete'
|
||||
)
|
||||
);
|
||||
@@ -301,11 +297,11 @@ test('should apply readonly doc restrictions while keeping cleanup actions', asy
|
||||
}
|
||||
await policy.reconcileWorkspaceQuotaState(ws.id);
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.false(permissions['Doc.Update']);
|
||||
t.false(permissions['Doc.Publish']);
|
||||
|
||||
@@ -1,20 +1,84 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { Models } from '../../../models';
|
||||
import { AccessControllerBuilder } from '../builder';
|
||||
import { PermissionDiagnosticService } from '../diagnostic';
|
||||
import { DocRole, PermissionModule, WorkspaceRole } from '../index';
|
||||
import { PermissionSqlPredicateBuilder } from '../sql-predicate';
|
||||
import type { DocAction } from '../types';
|
||||
|
||||
const module = await createModule({
|
||||
imports: [PermissionModule],
|
||||
});
|
||||
|
||||
const builder = module.get(AccessControllerBuilder);
|
||||
const models = module.get(Models);
|
||||
const db = module.get(PrismaClient);
|
||||
const diagnostic = module.get(PermissionDiagnosticService);
|
||||
const sqlPredicate = module.get(PermissionSqlPredicateBuilder);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
async function sqlReadableDocIds(input: {
|
||||
workspaceId: string;
|
||||
userId?: string;
|
||||
action?: DocAction;
|
||||
docIds: string[];
|
||||
}) {
|
||||
const values = Prisma.join(
|
||||
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
|
||||
);
|
||||
const predicate = sqlPredicate.docReadableByNewTablesSql({
|
||||
workspaceId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
action: input.action ?? 'Doc.Read',
|
||||
docIdColumn: Prisma.raw('c.doc_id'),
|
||||
});
|
||||
const rows = await db.$queryRaw<{ docId: string }[]>`
|
||||
WITH candidates(doc_id, ord) AS (VALUES ${values})
|
||||
SELECT c.doc_id AS "docId"
|
||||
FROM candidates c
|
||||
WHERE ${predicate}
|
||||
ORDER BY c.ord ASC
|
||||
`;
|
||||
return rows.map(row => row.docId);
|
||||
}
|
||||
|
||||
async function resetProjection(workspaceId: string) {
|
||||
await db.$executeRaw`DELETE FROM doc_grants WHERE workspace_id = ${workspaceId}`;
|
||||
await db.$executeRaw`DELETE FROM doc_access_policies WHERE workspace_id = ${workspaceId}`;
|
||||
await db.$executeRaw`DELETE FROM workspace_members WHERE workspace_id = ${workspaceId}`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_access_policies (
|
||||
workspace_id,
|
||||
visibility,
|
||||
sharing_enabled,
|
||||
url_preview_enabled,
|
||||
member_default_doc_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspaceId}, 'private', true, false, 'none', now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
visibility = EXCLUDED.visibility,
|
||||
sharing_enabled = EXCLUDED.sharing_enabled,
|
||||
url_preview_enabled = EXCLUDED.url_preview_enabled,
|
||||
member_default_doc_role = EXCLUDED.member_default_doc_role,
|
||||
updated_at = now()
|
||||
`;
|
||||
await models.workspaceRuntimeState.upsert(workspaceId, {
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
});
|
||||
}
|
||||
|
||||
test('should filter docs by Doc.Read', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
@@ -79,11 +143,329 @@ test('should filter docs by Doc.Read', async t => {
|
||||
t.is(docs3.length, 0);
|
||||
});
|
||||
|
||||
test('SQL doc read predicate matches Rust for projection default and public candidates', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await resetProjection(workspace.id);
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_access_policies
|
||||
SET member_default_doc_role = 'reader'
|
||||
WHERE workspace_id = ${workspace.id}
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_members (
|
||||
workspace_id,
|
||||
user_id,
|
||||
role,
|
||||
state,
|
||||
source,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_access_policies (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
visibility,
|
||||
public_role,
|
||||
member_default_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, 'member-default-none', 'private', NULL, 'none', now()),
|
||||
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now())
|
||||
`;
|
||||
|
||||
const docIds = ['missing-policy', 'member-default-none', 'public-doc'];
|
||||
const sqlReadable = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
docIds,
|
||||
});
|
||||
const shadow = await diagnostic.shadowSqlDocRead({
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
docs: docIds.map(docId => ({ docId })),
|
||||
sqlReadableDocIds: sqlReadable,
|
||||
});
|
||||
|
||||
t.deepEqual(sqlReadable, ['missing-policy', 'public-doc']);
|
||||
t.true(shadow.matched);
|
||||
});
|
||||
|
||||
test('SQL doc read predicate matches Rust for non-member grant and sharing disabled', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const nonMember = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await resetProjection(workspace.id);
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_access_policies (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
visibility,
|
||||
public_role,
|
||||
member_default_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now()),
|
||||
(${workspace.id}, 'private-doc', 'private', NULL, NULL, now()),
|
||||
(${workspace.id}, 'explicit-grant', 'private', NULL, NULL, now()),
|
||||
(${workspace.id}, 'explicit-owner-grant', 'private', NULL, NULL, now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_grants (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
principal_type,
|
||||
principal_id,
|
||||
role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
${workspace.id},
|
||||
'explicit-grant',
|
||||
'user',
|
||||
${nonMember.id},
|
||||
'reader',
|
||||
now()
|
||||
),
|
||||
(
|
||||
${workspace.id},
|
||||
'explicit-owner-grant',
|
||||
'user',
|
||||
${nonMember.id},
|
||||
'owner',
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
const docIds = [
|
||||
'public-doc',
|
||||
'private-doc',
|
||||
'explicit-grant',
|
||||
'explicit-owner-grant',
|
||||
];
|
||||
const sharingEnabledReadable = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docIds,
|
||||
});
|
||||
const sharingEnabledShadow = await diagnostic.shadowSqlDocRead({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docs: docIds.map(docId => ({ docId })),
|
||||
sqlReadableDocIds: sharingEnabledReadable,
|
||||
});
|
||||
const sharingEnabledUpdate = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
action: 'Doc.Update',
|
||||
docIds,
|
||||
});
|
||||
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_access_policies
|
||||
SET sharing_enabled = false
|
||||
WHERE workspace_id = ${workspace.id}
|
||||
`;
|
||||
const sharingDisabledReadable = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docIds,
|
||||
});
|
||||
const sharingDisabledShadow = await diagnostic.shadowSqlDocRead({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docs: docIds.map(docId => ({ docId })),
|
||||
sqlReadableDocIds: sharingDisabledReadable,
|
||||
});
|
||||
|
||||
t.deepEqual(sharingEnabledReadable, [
|
||||
'public-doc',
|
||||
'explicit-grant',
|
||||
'explicit-owner-grant',
|
||||
]);
|
||||
t.true(sharingEnabledShadow.matched);
|
||||
t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']);
|
||||
t.deepEqual(sharingDisabledReadable, []);
|
||||
t.true(sharingDisabledShadow.matched);
|
||||
});
|
||||
|
||||
test('SQL doc predicate suppresses member default when explicit grant exists', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await resetProjection(workspace.id);
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_access_policies
|
||||
SET member_default_doc_role = 'manager'
|
||||
WHERE workspace_id = ${workspace.id}
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_members (
|
||||
workspace_id,
|
||||
user_id,
|
||||
role,
|
||||
state,
|
||||
source,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_access_policies (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
visibility,
|
||||
public_role,
|
||||
member_default_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, 'default-manager', 'private', NULL, NULL, now()),
|
||||
(${workspace.id}, 'explicit-reader', 'private', NULL, NULL, now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_grants (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
principal_type,
|
||||
principal_id,
|
||||
role,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
'explicit-reader',
|
||||
'user',
|
||||
${member.id},
|
||||
'reader',
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
const docIds = ['default-manager', 'explicit-reader'];
|
||||
const sqlUpdateAllowed = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
action: 'Doc.Update',
|
||||
docIds,
|
||||
});
|
||||
|
||||
t.deepEqual(sqlUpdateAllowed, ['default-manager']);
|
||||
});
|
||||
|
||||
test('legacy SQL doc predicate matches external row and explicit grant cap semantics', async t => {
|
||||
const workspaceId = randomUUID();
|
||||
const memberId = randomUUID();
|
||||
const externalId = randomUUID();
|
||||
|
||||
async function fixtureLegacyDocIds(input: {
|
||||
userId: string;
|
||||
action: DocAction;
|
||||
docIds: string[];
|
||||
}) {
|
||||
const values = Prisma.join(
|
||||
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
|
||||
);
|
||||
const predicate = sqlPredicate.docReadableByLegacyTablesSql({
|
||||
workspaceId,
|
||||
userId: input.userId,
|
||||
action: input.action,
|
||||
docIdColumn: Prisma.raw('c.doc_id'),
|
||||
});
|
||||
// Current triggers reject newly inserted legacy External workspace rows;
|
||||
// CTEs let the same predicate run in Postgres against historical shapes.
|
||||
const rows = await db.$queryRaw<{ docId: string }[]>`
|
||||
WITH
|
||||
workspaces(id, enable_sharing) AS (
|
||||
VALUES (${workspaceId}, true)
|
||||
),
|
||||
workspace_pages(workspace_id, page_id, public, "defaultRole") AS (
|
||||
VALUES
|
||||
(${workspaceId}, 'default-manager', false, ${DocRole.Manager}::smallint),
|
||||
(${workspaceId}, 'explicit-reader', false, ${DocRole.Manager}::smallint),
|
||||
(${workspaceId}, 'external-owner', false, ${DocRole.Manager}::smallint),
|
||||
(${workspaceId}, 'dirty-external', false, ${DocRole.Manager}::smallint)
|
||||
),
|
||||
workspace_user_permissions(
|
||||
id,
|
||||
workspace_id,
|
||||
user_id,
|
||||
status,
|
||||
type
|
||||
) AS (
|
||||
VALUES
|
||||
(${randomUUID()}, ${workspaceId}, ${memberId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.Collaborator}::smallint),
|
||||
(${randomUUID()}, ${workspaceId}, ${externalId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.External}::smallint)
|
||||
),
|
||||
workspace_page_user_permissions(
|
||||
workspace_id,
|
||||
page_id,
|
||||
user_id,
|
||||
type
|
||||
) AS (
|
||||
VALUES
|
||||
(${workspaceId}, 'explicit-reader', ${memberId}, ${DocRole.Reader}::smallint),
|
||||
(${workspaceId}, 'external-owner', ${externalId}, ${DocRole.Owner}::smallint),
|
||||
(${workspaceId}, 'dirty-external', ${externalId}, ${DocRole.External}::smallint)
|
||||
),
|
||||
candidates(doc_id, ord) AS (VALUES ${values})
|
||||
SELECT c.doc_id AS "docId"
|
||||
FROM candidates c
|
||||
WHERE ${predicate}
|
||||
ORDER BY c.ord ASC
|
||||
`;
|
||||
return rows.map(row => row.docId);
|
||||
}
|
||||
|
||||
const memberUpdateAllowed = await fixtureLegacyDocIds({
|
||||
userId: memberId,
|
||||
action: 'Doc.Update',
|
||||
docIds: ['default-manager', 'explicit-reader'],
|
||||
});
|
||||
const externalUpdateAllowed = await fixtureLegacyDocIds({
|
||||
userId: externalId,
|
||||
action: 'Doc.Update',
|
||||
docIds: ['external-owner', 'dirty-external'],
|
||||
});
|
||||
const externalManageAllowed = await fixtureLegacyDocIds({
|
||||
userId: externalId,
|
||||
action: 'Doc.Users.Manage',
|
||||
docIds: ['external-owner', 'dirty-external'],
|
||||
});
|
||||
const externalTransferAllowed = await fixtureLegacyDocIds({
|
||||
userId: externalId,
|
||||
action: 'Doc.TransferOwner',
|
||||
docIds: ['external-owner', 'dirty-external'],
|
||||
});
|
||||
|
||||
t.deepEqual(memberUpdateAllowed, ['default-manager']);
|
||||
t.deepEqual(externalUpdateAllowed, ['external-owner']);
|
||||
t.deepEqual(externalManageAllowed, []);
|
||||
t.deepEqual(externalTransferAllowed, []);
|
||||
});
|
||||
|
||||
test('should filter docs by Doc.Publish', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await models.workspace.update(workspace.id, { enableSharing: true });
|
||||
await models.workspaceRuntimeState.upsert(workspace.id, {
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
});
|
||||
|
||||
const docs1 = await builder
|
||||
.user(owner.id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
@@ -7,11 +8,6 @@ import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import {
|
||||
DocActionDenied,
|
||||
OwnerCanNotLeaveWorkspace,
|
||||
SpaceAccessDenied,
|
||||
} from '../../../base';
|
||||
import {
|
||||
Models,
|
||||
User,
|
||||
@@ -19,25 +15,59 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { QuotaService } from '../../quota/service';
|
||||
import { QuotaServiceModule } from '../../quota/service.module';
|
||||
import { QuotaStateService } from '../../quota/state';
|
||||
import { PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
policy: WorkspacePolicyService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
const READONLY_FEATURE = 'quota_exceeded_readonly_workspace_v1' as const;
|
||||
type WorkspaceQuotaSnapshot = Awaited<
|
||||
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
|
||||
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
|
||||
> & {
|
||||
ownerQuota?: string;
|
||||
readonlyReasons: string[];
|
||||
};
|
||||
|
||||
const readonlyWorkspaceState = (
|
||||
workspaceId: string,
|
||||
readonlyReasons: string[],
|
||||
overrides: Partial<WorkspaceQuotaSnapshot> = {}
|
||||
) =>
|
||||
({
|
||||
workspaceId,
|
||||
plan: 'free',
|
||||
sourceEntitlementId: null,
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: readonlyReasons.includes('member_overflow')
|
||||
? 1
|
||||
: 0,
|
||||
blobLimit: BigInt(1),
|
||||
storageQuota: BigInt(1),
|
||||
usedStorageQuota: readonlyReasons.includes('storage_overflow')
|
||||
? BigInt(2)
|
||||
: BigInt(0),
|
||||
historyPeriodSeconds: 1,
|
||||
readonly: readonlyReasons.length > 0,
|
||||
readonlyReasons,
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: new Date(),
|
||||
staleAfter: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) satisfies WorkspaceQuotaSnapshot;
|
||||
async function addAcceptedMembers(
|
||||
models: Models,
|
||||
workspaceId: string,
|
||||
@@ -64,6 +94,7 @@ let workspace: Workspace;
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [PermissionModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
@@ -81,21 +112,23 @@ test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should reuse quota service exported by quota service module', async t => {
|
||||
test('should reuse quota state service exported by quota service module', async t => {
|
||||
const module = await createTestingModule(
|
||||
{ imports: [PermissionModule, QuotaServiceModule] },
|
||||
false
|
||||
);
|
||||
|
||||
try {
|
||||
const quota = module.select(QuotaServiceModule).get(QuotaService, {
|
||||
strict: true,
|
||||
});
|
||||
const quotaState = module
|
||||
.select(QuotaServiceModule)
|
||||
.get(QuotaStateService, {
|
||||
strict: true,
|
||||
});
|
||||
const policy = module.select(PermissionModule).get(WorkspacePolicyService, {
|
||||
strict: true,
|
||||
});
|
||||
|
||||
t.is(Reflect.get(policy, 'quota'), quota);
|
||||
t.is(Reflect.get(policy, 'quotaState'), quotaState);
|
||||
} finally {
|
||||
await module.close();
|
||||
}
|
||||
@@ -108,12 +141,9 @@ test('should keep owned workspace writable when quota is within limit', async t
|
||||
|
||||
t.false(state.isReadonly);
|
||||
t.deepEqual(state.readonlyReasons, []);
|
||||
t.false(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
});
|
||||
|
||||
test('should enter readonly mode when fallback owner member quota overflows', async t => {
|
||||
test('should report readonly state when fallback owner member quota overflows', async t => {
|
||||
await addAcceptedMembers(t.context.models, workspace.id, 10);
|
||||
|
||||
const state = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
@@ -124,91 +154,16 @@ test('should enter readonly mode when fallback owner member quota overflows', as
|
||||
t.true(state.canRecoverByRemovingMembers);
|
||||
t.false(state.canRecoverByDeletingBlobs);
|
||||
t.deepEqual(state.readonlyReasons, ['member_overflow']);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
await t.throwsAsync(t.context.policy.assertCanInviteMembers(workspace.id), {
|
||||
instanceOf: SpaceAccessDenied,
|
||||
});
|
||||
});
|
||||
|
||||
test('should deny blob uploads when user no longer has write access', async t => {
|
||||
const external = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
external.id,
|
||||
WorkspaceRole.External,
|
||||
{ status: WorkspaceMemberStatus.Accepted }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanUploadBlob(external.id, workspace.id),
|
||||
{ instanceOf: SpaceAccessDenied }
|
||||
);
|
||||
});
|
||||
|
||||
test('should deny publish through policy when workspace sharing is disabled', async t => {
|
||||
await t.context.models.workspace.update(workspace.id, {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanPublishDoc(owner.id, workspace.id, 'doc1'),
|
||||
{ instanceOf: DocActionDenied }
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanUnpublishDoc(owner.id, workspace.id, 'doc1')
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow managers to revoke invite links in readonly workspace', async t => {
|
||||
await addAcceptedMembers(t.context.models, workspace.id, 10);
|
||||
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
|
||||
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanManageInviteLink(owner.id, workspace.id)
|
||||
);
|
||||
});
|
||||
|
||||
test('should apply leave workspace policy by role', async t => {
|
||||
const collaborator = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
collaborator.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{ status: WorkspaceMemberStatus.Accepted }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanLeaveWorkspace(owner.id, workspace.id),
|
||||
{ instanceOf: OwnerCanNotLeaveWorkspace }
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanLeaveWorkspace(collaborator.id, workspace.id)
|
||||
);
|
||||
});
|
||||
|
||||
test('should enter readonly mode when fallback owner storage quota overflows', async t => {
|
||||
const quota = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quota') as QuotaService,
|
||||
'getWorkspaceQuotaWithUsage'
|
||||
const quotaState = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
|
||||
'reconcileWorkspaceQuotaState'
|
||||
);
|
||||
quotaState.callsFake(async workspaceId =>
|
||||
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
|
||||
);
|
||||
quota.resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 2,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 2,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
|
||||
const state = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
@@ -218,57 +173,26 @@ test('should enter readonly mode when fallback owner storage quota overflows', a
|
||||
t.false(state.canRecoverByRemovingMembers);
|
||||
t.true(state.canRecoverByDeletingBlobs);
|
||||
t.deepEqual(state.readonlyReasons, ['storage_overflow']);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
});
|
||||
|
||||
test('should leave readonly mode after workspace usage recovers', async t => {
|
||||
const quota = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quota') as QuotaService,
|
||||
'getWorkspaceQuotaWithUsage'
|
||||
test('should report recovered state after workspace usage recovers', async t => {
|
||||
const quotaState = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
|
||||
'reconcileWorkspaceQuotaState'
|
||||
);
|
||||
quota.onFirstCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 2,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 2,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quota.onSecondCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 0,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quota.onThirdCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 0,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quotaState
|
||||
.onFirstCall()
|
||||
.callsFake(async workspaceId =>
|
||||
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
|
||||
);
|
||||
quotaState
|
||||
.onSecondCall()
|
||||
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
|
||||
quotaState
|
||||
.onThirdCall()
|
||||
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
|
||||
|
||||
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
|
||||
const recovered = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
@@ -276,10 +200,6 @@ test('should leave readonly mode after workspace usage recovers', async t => {
|
||||
|
||||
t.false(recovered.isReadonly);
|
||||
t.deepEqual(recovered.readonlyReasons, []);
|
||||
t.false(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
await t.notThrowsAsync(t.context.policy.assertCanInviteMembers(workspace.id));
|
||||
});
|
||||
|
||||
test('should roll back team cancellation cleanup when cleanup fails', async t => {
|
||||
@@ -289,11 +209,58 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
|
||||
const admin = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
pending.id,
|
||||
WorkspaceRole.Collaborator
|
||||
);
|
||||
await t.context.db.$transaction(async db => {
|
||||
await db.$executeRaw`
|
||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
||||
`;
|
||||
const pendingPermission = await db.workspaceUserRole.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId: pending.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
},
|
||||
});
|
||||
const [invitationShape] = await db.$queryRaw<Array<{ current: boolean }>>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_invitations'
|
||||
AND column_name = 'requested_role'
|
||||
) AS "current"
|
||||
`;
|
||||
if (invitationShape?.current) {
|
||||
await db.workspaceInvitation.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: pending.id,
|
||||
requestedRole: 'member',
|
||||
status: 'pending',
|
||||
kind: 'email',
|
||||
legacyPermissionId: pendingPermission.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_invitations (
|
||||
workspace_id,
|
||||
invitee_user_id,
|
||||
role,
|
||||
state,
|
||||
source,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
${pending.id},
|
||||
${'member'},
|
||||
${'pending'},
|
||||
${'email'},
|
||||
now()
|
||||
)
|
||||
`;
|
||||
}
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
admin.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,13 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { PermissionModule } from '../index';
|
||||
import { PermissionAccess, PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
import { mapWorkspaceRoleToPermissions } from '../types';
|
||||
import { WorkspaceAccessController } from '../workspace';
|
||||
|
||||
let module: TestingModule;
|
||||
let models: Models;
|
||||
let ac: WorkspaceAccessController;
|
||||
let ac: PermissionAccess;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
@@ -26,7 +25,7 @@ let underReviewUserId: string;
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
models = module.get<Models>(Models);
|
||||
ac = module.get(WorkspaceAccessController);
|
||||
ac = module.get(PermissionAccess);
|
||||
policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
|
||||
@@ -138,10 +137,34 @@ const roleCases: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
async function getRole(resource: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
|
||||
if (resource.allowLocal) {
|
||||
checker.allowLocal();
|
||||
}
|
||||
return (await checker.permissions()).role;
|
||||
}
|
||||
|
||||
function workspace(resource: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
|
||||
if (resource.allowLocal) {
|
||||
checker.allowLocal();
|
||||
}
|
||||
return checker;
|
||||
}
|
||||
|
||||
for (const roleCase of roleCases) {
|
||||
test(roleCase.title, async t => {
|
||||
await roleCase.setup?.();
|
||||
const role = await ac.getRole(roleCase.resource());
|
||||
const role = await getRole(roleCase.resource());
|
||||
|
||||
t.is(role, roleCase.expectedRole);
|
||||
});
|
||||
@@ -150,10 +173,10 @@ for (const roleCase of roleCases) {
|
||||
test('should return mapped null permission even workspace has public docs', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(null));
|
||||
});
|
||||
@@ -162,13 +185,10 @@ test('should deny external read assert even workspace has public docs', async t
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Workspace.Read'
|
||||
)
|
||||
workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}).assert('Workspace.Read')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -177,13 +197,10 @@ test('should deny external read assert when sharing disabled even if workspace h
|
||||
await models.workspace.update(ws.id, { enableSharing: false });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Workspace.Read'
|
||||
)
|
||||
workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}).assert('Workspace.Read')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -193,31 +210,27 @@ test('should reject external doc roles when sharing disabled', async t => {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
const [docRole] = await ac.docRoles(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
['doc1']
|
||||
);
|
||||
const docRole = await ac
|
||||
.user('random-user-id')
|
||||
.doc(ws.id, 'doc1')
|
||||
.permissions();
|
||||
|
||||
t.is(docRole.role, null);
|
||||
t.false(docRole.permissions['Doc.Read']);
|
||||
});
|
||||
|
||||
test('should return mapped permissions', async t => {
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(WorkspaceRole.Owner));
|
||||
});
|
||||
|
||||
test('should assert action', async t => {
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, userId: user.id },
|
||||
workspace({ workspaceId: ws.id, userId: user.id }).assert(
|
||||
'Workspace.TransferOwner'
|
||||
)
|
||||
);
|
||||
@@ -225,7 +238,7 @@ test('should assert action', async t => {
|
||||
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
|
||||
workspace({ workspaceId: ws.id, userId: u2.id }).assert('Workspace.Sync')
|
||||
);
|
||||
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Admin, {
|
||||
@@ -233,8 +246,7 @@ test('should assert action', async t => {
|
||||
});
|
||||
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, userId: u2.id },
|
||||
workspace({ workspaceId: ws.id, userId: u2.id }).assert(
|
||||
'Workspace.Settings.Update'
|
||||
)
|
||||
);
|
||||
@@ -256,10 +268,10 @@ test('should apply readonly workspace restrictions while keeping cleanup actions
|
||||
}
|
||||
await policy.reconcileWorkspaceQuotaState(ws.id);
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.false(permissions['Workspace.CreateDoc']);
|
||||
t.false(permissions['Workspace.Settings.Update']);
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DocID } from '../utils/doc';
|
||||
import { getAccessController } from './controller';
|
||||
import { Resource } from './resource';
|
||||
import { DocAction, WorkspaceAction } from './types';
|
||||
import { WorkspaceAccessController } from './workspace';
|
||||
import { PermissionService } from './service';
|
||||
import {
|
||||
DOC_ACTIONS,
|
||||
DocAction,
|
||||
DocRole,
|
||||
WORKSPACE_ACTIONS,
|
||||
WorkspaceAction,
|
||||
WorkspaceRole,
|
||||
} from './types';
|
||||
|
||||
function assertPerm(permission?: PermissionService) {
|
||||
if (!permission) {
|
||||
throw new Error('PermissionService is required for permission checks.');
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessControllerBuilder {
|
||||
constructor(private readonly permission?: PermissionService) {}
|
||||
|
||||
user(userId: string) {
|
||||
return new UserAccessControllerBuilder(userId);
|
||||
return new UserAccessControllerBuilder(userId, this.permission);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccessControllerBuilder {
|
||||
constructor(private readonly userId: string) {}
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
private readonly permission?: PermissionService
|
||||
) {}
|
||||
|
||||
workspace(workspaceId: string) {
|
||||
return new WorkspaceAccessControllerBuilder({
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
});
|
||||
return new WorkspaceAccessControllerBuilder(
|
||||
{
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
},
|
||||
this.permission
|
||||
);
|
||||
}
|
||||
|
||||
doc(
|
||||
@@ -45,16 +66,22 @@ export class UserAccessControllerBuilder {
|
||||
docId = docIdOrWorkspaceId.docId;
|
||||
}
|
||||
|
||||
return new DocAccessControllerBuilder({
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
docId,
|
||||
});
|
||||
return new DocAccessControllerBuilder(
|
||||
{
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
docId,
|
||||
},
|
||||
this.permission
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkspaceAccessControllerBuilder {
|
||||
constructor(public readonly data: Resource<'ws'>) {}
|
||||
constructor(
|
||||
public readonly data: Resource<'ws'>,
|
||||
private readonly permission?: PermissionService
|
||||
) {}
|
||||
|
||||
allowLocal() {
|
||||
this.data.allowLocal = true;
|
||||
@@ -62,10 +89,13 @@ class WorkspaceAccessControllerBuilder {
|
||||
}
|
||||
|
||||
doc(docId: string) {
|
||||
return new DocAccessControllerBuilder({
|
||||
...this.data,
|
||||
docId,
|
||||
});
|
||||
return new DocAccessControllerBuilder(
|
||||
{
|
||||
...this.data,
|
||||
docId,
|
||||
},
|
||||
this.permission
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,35 +109,61 @@ class WorkspaceAccessControllerBuilder {
|
||||
action: DocAction
|
||||
): Promise<T[]> {
|
||||
const docIds = items.map(item => item.docId);
|
||||
const checker = getAccessController('ws') as WorkspaceAccessController;
|
||||
const docRoles = await checker.docRoles(this.data, docIds);
|
||||
const docRoles = await assertPerm(this.permission).batchDocPermissions({
|
||||
userId: this.data.userId,
|
||||
workspaceId: this.data.workspaceId,
|
||||
docs: docIds.map(docId => ({
|
||||
docId,
|
||||
actions: [action],
|
||||
})),
|
||||
allowLocal: this.data.allowLocal,
|
||||
});
|
||||
const docRolesMap = new Map(
|
||||
docRoles.map((role, index) => [docIds[index], role])
|
||||
);
|
||||
|
||||
return items.filter(item => {
|
||||
return docRolesMap.get(item.docId)?.permissions[action];
|
||||
return docRolesMap
|
||||
.get(item.docId)
|
||||
?.decisions.some(
|
||||
decision => decision.action === action && decision.allowed
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async assert(action: WorkspaceAction) {
|
||||
const checker = getAccessController('ws');
|
||||
await checker.assert(this.data, action);
|
||||
await assertPerm(this.permission).assertWorkspace({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async can(action: WorkspaceAction) {
|
||||
const checker = getAccessController('ws');
|
||||
return await checker.can(this.data, action);
|
||||
return await assertPerm(this.permission).canWorkspace({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async permissions() {
|
||||
const checker = getAccessController('ws');
|
||||
return await checker.role(this.data);
|
||||
const result = await assertPerm(this.permission).workspacePermissions({
|
||||
...this.data,
|
||||
actions: [...WORKSPACE_ACTIONS],
|
||||
});
|
||||
return {
|
||||
role: result.legacyApiRole as WorkspaceRole | null,
|
||||
permissions: Object.fromEntries(
|
||||
result.decisions.map(decision => [decision.action, decision.allowed])
|
||||
) as Record<WorkspaceAction, boolean>,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocAccessControllerBuilder {
|
||||
constructor(public readonly data: Resource<'doc'>) {}
|
||||
constructor(
|
||||
public readonly data: Resource<'doc'>,
|
||||
private readonly permission?: PermissionService
|
||||
) {}
|
||||
|
||||
allowLocal() {
|
||||
this.data.allowLocal = true;
|
||||
@@ -115,17 +171,29 @@ class DocAccessControllerBuilder {
|
||||
}
|
||||
|
||||
async assert(action: DocAction) {
|
||||
const checker = getAccessController('doc');
|
||||
await checker.assert(this.data, action);
|
||||
await assertPerm(this.permission).assertDoc({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async can(action: DocAction) {
|
||||
const checker = getAccessController('doc');
|
||||
return await checker.can(this.data, action);
|
||||
return await assertPerm(this.permission).canDoc({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async permissions() {
|
||||
const checker = getAccessController('doc');
|
||||
return await checker.role(this.data);
|
||||
const result = await assertPerm(this.permission).docPermissions({
|
||||
...this.data,
|
||||
actions: [...DOC_ACTIONS],
|
||||
});
|
||||
return {
|
||||
role: result.legacyApiRole as DocRole | null,
|
||||
permissions: Object.fromEntries(
|
||||
result.decisions.map(decision => [decision.action, decision.allowed])
|
||||
) as Record<DocAction, boolean>,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
export enum PermissionReadModel {
|
||||
Legacy = 'legacy',
|
||||
Projection = 'projection',
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
permission: {
|
||||
readModel: PermissionReadModel;
|
||||
fallbackLegacyLoader: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('permission', {
|
||||
readModel: {
|
||||
desc: 'Permission data source for Rust evaluation',
|
||||
default: PermissionReadModel.Projection,
|
||||
shape: z.nativeEnum(PermissionReadModel),
|
||||
env: ['AFFINE_PERMISSION_READ_MODEL', 'string'],
|
||||
},
|
||||
fallbackLegacyLoader: {
|
||||
desc: 'Fallback from projection loader to legacy loader when projection input loading fails',
|
||||
default: false,
|
||||
env: ['AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER', 'boolean'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
import { DocRole, Models } from '../../models';
|
||||
import type { PermissionEvaluationInputV1 } from '../../native';
|
||||
import {
|
||||
toNativeDocRole,
|
||||
toNativeExplicitDocGrantRole,
|
||||
toNativeMemberState,
|
||||
toNativeWorkspaceRole,
|
||||
} from './context';
|
||||
import type { DocAction, WorkspaceAction } from './types';
|
||||
|
||||
type PermissionRequestCache = {
|
||||
workspaceMember: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['workspaceUser']['get']>>
|
||||
>;
|
||||
workspacePolicy: Map<string, Awaited<ReturnType<Models['workspace']['get']>>>;
|
||||
workspaceRuntime: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['workspaceRuntimeState']['get']>>
|
||||
>;
|
||||
workspaceQuotaRuntime: Map<string, NewWorkspaceRuntimeState>;
|
||||
docPolicies: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['doc']['findDefaultRoles']>>
|
||||
>;
|
||||
docGrants: Map<string, Awaited<ReturnType<Models['docUser']['findMany']>>>;
|
||||
};
|
||||
|
||||
type NewWorkspaceMemberRow = {
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
state: 'active' | 'suspended' | 'left';
|
||||
};
|
||||
|
||||
type NewWorkspacePolicyRow = {
|
||||
visibility: 'private' | 'public';
|
||||
sharingEnabled: boolean;
|
||||
urlPreviewEnabled: boolean;
|
||||
memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager';
|
||||
};
|
||||
|
||||
type NewDocPolicyRow = {
|
||||
docId: string;
|
||||
visibility: 'private' | 'public';
|
||||
publicRole: 'external' | null;
|
||||
memberDefaultRole:
|
||||
| 'none'
|
||||
| 'reader'
|
||||
| 'commenter'
|
||||
| 'editor'
|
||||
| 'manager'
|
||||
| null;
|
||||
urlPreviewEnabled: boolean;
|
||||
};
|
||||
|
||||
type NewDocGrantRow = {
|
||||
docId: string;
|
||||
role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
|
||||
};
|
||||
|
||||
type NewWorkspaceRuntimeState = {
|
||||
known: boolean;
|
||||
stale: boolean;
|
||||
readonly: boolean;
|
||||
readonlyReasons: string[];
|
||||
staleAfter: Date | null;
|
||||
};
|
||||
|
||||
const CACHE_KEY = 'permission.context.cache';
|
||||
|
||||
function createPermissionRequestCache(): PermissionRequestCache {
|
||||
return {
|
||||
workspaceMember: new Map(),
|
||||
workspacePolicy: new Map(),
|
||||
workspaceRuntime: new Map(),
|
||||
workspaceQuotaRuntime: new Map(),
|
||||
docPolicies: new Map(),
|
||||
docGrants: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview';
|
||||
export type PermissionDocAction = DocAction | 'Doc.Preview';
|
||||
|
||||
function cacheKey(parts: readonly unknown[]) {
|
||||
return parts.join('\0');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PermissionContextLoader {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly cls?: ClsService
|
||||
) {}
|
||||
|
||||
async load(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
allowLocal?: boolean;
|
||||
workspaceActions?: PermissionWorkspaceAction[];
|
||||
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||
}): Promise<PermissionEvaluationInputV1> {
|
||||
const docs = input.docs ?? [];
|
||||
const [member, workspace, runtime, docPolicies, docGrants] =
|
||||
await Promise.all([
|
||||
input.userId
|
||||
? this.workspaceMember(input.workspaceId, input.userId)
|
||||
: Promise.resolve(null),
|
||||
this.workspacePolicy(input.workspaceId),
|
||||
this.workspaceRuntime(input.workspaceId),
|
||||
this.docPolicies(
|
||||
input.workspaceId,
|
||||
docs.map(doc => doc.docId)
|
||||
),
|
||||
input.userId
|
||||
? this.docGrants(
|
||||
input.workspaceId,
|
||||
docs.map(doc => doc.docId),
|
||||
input.userId
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
|
||||
const workspaceSharingEnabled = workspace?.enableSharing ?? true;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
legacyCompatMode: true,
|
||||
subject: {
|
||||
userId: input.userId,
|
||||
groupIds: [],
|
||||
allowLocal: input.allowLocal,
|
||||
},
|
||||
runtime: {
|
||||
known: runtime.known,
|
||||
stale: runtime.stale,
|
||||
readonly: runtime.readonly,
|
||||
readonlyReason: runtime.readonlyReasons[0],
|
||||
sharingEnabled: workspaceSharingEnabled,
|
||||
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
|
||||
},
|
||||
workspace: {
|
||||
role: toNativeWorkspaceRole(member?.type),
|
||||
memberState: toNativeMemberState(member?.status),
|
||||
public: workspace?.public ?? false,
|
||||
sharingEnabled: workspaceSharingEnabled,
|
||||
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
|
||||
local: !workspace,
|
||||
},
|
||||
workspaceActions: input.workspaceActions,
|
||||
docs: docs.map((doc, index) => {
|
||||
const policy = docPolicies[index];
|
||||
const grant = docGrantMap.get(doc.docId);
|
||||
return {
|
||||
docId: doc.docId,
|
||||
actions: doc.actions,
|
||||
explicitUserRole: toNativeExplicitDocGrantRole(grant?.type),
|
||||
groupGrants: [],
|
||||
groupGrantsEnabled: false,
|
||||
memberDefaultRole: toNativeDocRole(
|
||||
policy?.workspace ?? DocRole.Manager
|
||||
),
|
||||
publicRole: policy?.external === null ? undefined : 'external',
|
||||
visibility: policy?.external === null ? 'private' : 'public',
|
||||
sharingEnabled: workspaceSharingEnabled,
|
||||
previewEnabled: policy?.external !== null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromNewTables(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
allowLocal?: boolean;
|
||||
workspaceActions?: PermissionWorkspaceAction[];
|
||||
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||
}): Promise<PermissionEvaluationInputV1> {
|
||||
const docs = input.docs ?? [];
|
||||
const docIds = docs.map(doc => doc.docId);
|
||||
const [member, workspacePolicy, runtime, docPolicies, docGrants] =
|
||||
await Promise.all([
|
||||
input.userId
|
||||
? this.newWorkspaceMember(input.workspaceId, input.userId)
|
||||
: Promise.resolve(null),
|
||||
this.newWorkspacePolicy(input.workspaceId),
|
||||
this.newWorkspaceRuntime(input.workspaceId),
|
||||
this.newDocPolicies(input.workspaceId, docIds),
|
||||
input.userId
|
||||
? this.newDocGrants(input.workspaceId, docIds, input.userId)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const docPolicyMap = new Map(
|
||||
docPolicies.map(policy => [policy.docId, policy])
|
||||
);
|
||||
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
|
||||
const local =
|
||||
!workspacePolicy &&
|
||||
!!input.allowLocal &&
|
||||
!(await this.workspaceExists(input.workspaceId));
|
||||
const sharingEnabled = workspacePolicy?.sharingEnabled ?? true;
|
||||
const urlPreviewEnabled = workspacePolicy?.urlPreviewEnabled ?? false;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
legacyCompatMode: true,
|
||||
subject: {
|
||||
userId: input.userId,
|
||||
groupIds: [],
|
||||
allowLocal: input.allowLocal,
|
||||
},
|
||||
runtime: {
|
||||
known: runtime.known,
|
||||
stale: runtime.stale,
|
||||
readonly: runtime.readonly,
|
||||
readonlyReason: runtime.readonlyReasons[0],
|
||||
sharingEnabled,
|
||||
urlPreviewEnabled,
|
||||
},
|
||||
workspace: {
|
||||
role: member?.role,
|
||||
memberState: member?.state === 'active' ? 'active' : undefined,
|
||||
public: workspacePolicy?.visibility === 'public',
|
||||
sharingEnabled,
|
||||
urlPreviewEnabled,
|
||||
local,
|
||||
},
|
||||
workspaceActions: input.workspaceActions,
|
||||
docs: docs.map(doc => {
|
||||
const policy = docPolicyMap.get(doc.docId);
|
||||
const grant = docGrantMap.get(doc.docId);
|
||||
const visibility = policy?.visibility ?? 'private';
|
||||
const publicRole = policy?.publicRole ?? undefined;
|
||||
return {
|
||||
docId: doc.docId,
|
||||
actions: doc.actions,
|
||||
explicitUserRole: grant?.role,
|
||||
groupGrants: [],
|
||||
groupGrantsEnabled: false,
|
||||
memberDefaultRole:
|
||||
policy?.memberDefaultRole ??
|
||||
workspacePolicy?.memberDefaultDocRole ??
|
||||
'manager',
|
||||
publicRole: publicRole === 'external' ? 'external' : undefined,
|
||||
visibility,
|
||||
sharingEnabled,
|
||||
previewEnabled:
|
||||
visibility === 'public' ||
|
||||
policy?.urlPreviewEnabled ||
|
||||
urlPreviewEnabled,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private get cache(): PermissionRequestCache {
|
||||
if (!this.cls) {
|
||||
return createPermissionRequestCache();
|
||||
}
|
||||
|
||||
if (typeof this.cls.isActive === 'function' && !this.cls.isActive()) {
|
||||
return createPermissionRequestCache();
|
||||
}
|
||||
|
||||
const existing = this.cls.get(CACHE_KEY) as
|
||||
| PermissionRequestCache
|
||||
| undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = createPermissionRequestCache();
|
||||
this.cls.set(CACHE_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private memo<T>(
|
||||
map: Map<string, Promise<T> | T>,
|
||||
key: string,
|
||||
load: () => Promise<T>
|
||||
) {
|
||||
const cached = map.get(key);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
const promise = load();
|
||||
map.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private workspaceMember(workspaceId: string, userId: string) {
|
||||
return this.memo(
|
||||
this.cache.workspaceMember,
|
||||
cacheKey([workspaceId, userId]),
|
||||
() => this.models.workspaceUser.get(workspaceId, userId)
|
||||
);
|
||||
}
|
||||
|
||||
private workspacePolicy(workspaceId: string) {
|
||||
return this.memo(this.cache.workspacePolicy, workspaceId, () =>
|
||||
this.models.workspace.get(workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
private async workspaceRuntime(workspaceId: string) {
|
||||
return this.memo(this.cache.workspaceRuntime, workspaceId, () =>
|
||||
this.models.workspaceRuntimeState.get(workspaceId).then(async state => {
|
||||
if (state.known || !state.stale) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const quotaState = await this.newWorkspaceRuntime(workspaceId);
|
||||
if (!quotaState.known) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
known: quotaState.known,
|
||||
stale: quotaState.stale,
|
||||
readonly: quotaState.readonly,
|
||||
readonlyReasons: quotaState.readonlyReasons,
|
||||
updatedAt: null,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: quotaState.staleAfter,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
invalidateWorkspaceQuotaRuntime(workspaceId: string) {
|
||||
this.cache.workspaceQuotaRuntime.delete(workspaceId);
|
||||
}
|
||||
|
||||
private newWorkspaceRuntime(workspaceId: string) {
|
||||
return this.memo(
|
||||
this.cache.workspaceQuotaRuntime,
|
||||
workspaceId,
|
||||
async () => {
|
||||
const rows = await this.db.$queryRaw<NewWorkspaceRuntimeState[]>`
|
||||
SELECT
|
||||
known,
|
||||
stale,
|
||||
readonly,
|
||||
readonly_reasons AS "readonlyReasons",
|
||||
stale_after AS "staleAfter"
|
||||
FROM effective_workspace_quota_states
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
LIMIT 1
|
||||
`;
|
||||
const state = rows[0];
|
||||
if (!state) {
|
||||
return {
|
||||
known: false,
|
||||
stale: true,
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
staleAfter: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
stale:
|
||||
state.stale ||
|
||||
(state.staleAfter !== null && state.staleAfter <= new Date()),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private docPolicies(workspaceId: string, docIds: string[]) {
|
||||
const uniqueDocIds = [...new Set(docIds)];
|
||||
return this.memo(
|
||||
this.cache.docPolicies,
|
||||
cacheKey([workspaceId, ...uniqueDocIds]),
|
||||
() => this.models.doc.findDefaultRoles(workspaceId, uniqueDocIds)
|
||||
);
|
||||
}
|
||||
|
||||
private docGrants(workspaceId: string, docIds: string[], userId: string) {
|
||||
const uniqueDocIds = [...new Set(docIds)];
|
||||
return this.memo(
|
||||
this.cache.docGrants,
|
||||
cacheKey([workspaceId, userId, ...uniqueDocIds]),
|
||||
() => this.models.docUser.findMany(workspaceId, uniqueDocIds, userId)
|
||||
);
|
||||
}
|
||||
|
||||
private async newWorkspaceMember(workspaceId: string, userId: string) {
|
||||
const rows = await this.db.$queryRaw<NewWorkspaceMemberRow[]>`
|
||||
SELECT role, state
|
||||
FROM workspace_members
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
AND user_id = ${userId}
|
||||
AND state = 'active'
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async newWorkspacePolicy(workspaceId: string) {
|
||||
const rows = await this.db.$queryRaw<NewWorkspacePolicyRow[]>`
|
||||
SELECT
|
||||
visibility,
|
||||
sharing_enabled AS "sharingEnabled",
|
||||
url_preview_enabled AS "urlPreviewEnabled",
|
||||
member_default_doc_role AS "memberDefaultDocRole"
|
||||
FROM workspace_access_policies
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async workspaceExists(workspaceId: string) {
|
||||
const workspace = await this.db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!workspace;
|
||||
}
|
||||
|
||||
private async newDocPolicies(workspaceId: string, docIds: string[]) {
|
||||
if (docIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await this.db.$queryRaw<NewDocPolicyRow[]>`
|
||||
SELECT
|
||||
doc_id AS "docId",
|
||||
visibility,
|
||||
public_role AS "publicRole",
|
||||
member_default_role AS "memberDefaultRole",
|
||||
url_preview_enabled AS "urlPreviewEnabled"
|
||||
FROM doc_access_policies
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
AND doc_id = ANY(${[...new Set(docIds)]})
|
||||
`;
|
||||
}
|
||||
|
||||
private async newDocGrants(
|
||||
workspaceId: string,
|
||||
docIds: string[],
|
||||
userId: string
|
||||
) {
|
||||
if (docIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await this.db.$queryRaw<NewDocGrantRow[]>`
|
||||
SELECT doc_id AS "docId", role
|
||||
FROM doc_grants
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
AND principal_type = 'user'
|
||||
AND principal_id = ${userId}
|
||||
AND doc_id = ANY(${[...new Set(docIds)]})
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||
|
||||
import type {
|
||||
PermissionDocRole,
|
||||
PermissionEvaluationInputV1,
|
||||
PermissionEvaluationOutputV1,
|
||||
PermissionWorkspaceRole,
|
||||
} from '../../native';
|
||||
import { DocRole, WorkspaceRole } from './types';
|
||||
|
||||
export type PermissionRuntimeState = NonNullable<
|
||||
PermissionEvaluationInputV1['runtime']
|
||||
>;
|
||||
|
||||
export type PermissionWorkspaceContext = NonNullable<
|
||||
PermissionEvaluationInputV1['workspace']
|
||||
>;
|
||||
|
||||
export type PermissionDocContext = NonNullable<
|
||||
NonNullable<PermissionEvaluationInputV1['docs']>[number]
|
||||
>;
|
||||
|
||||
export type PermissionLegacyRoleBoundary = {
|
||||
resourceOwnerRole: PermissionDocRole | PermissionWorkspaceRole | null;
|
||||
effectiveRole: PermissionDocRole | PermissionWorkspaceRole | null;
|
||||
legacyApiRole: DocRole | WorkspaceRole | null;
|
||||
};
|
||||
|
||||
const WORKSPACE_ROLE_TO_NATIVE = new Map<
|
||||
WorkspaceRole,
|
||||
PermissionWorkspaceRole
|
||||
>([
|
||||
[WorkspaceRole.External, 'external'],
|
||||
[WorkspaceRole.Collaborator, 'member'],
|
||||
[WorkspaceRole.Admin, 'admin'],
|
||||
[WorkspaceRole.Owner, 'owner'],
|
||||
]);
|
||||
|
||||
const DOC_ROLE_TO_NATIVE = new Map<DocRole, PermissionDocRole>([
|
||||
[DocRole.None, 'none'],
|
||||
[DocRole.External, 'external'],
|
||||
[DocRole.Reader, 'reader'],
|
||||
[DocRole.Commenter, 'commenter'],
|
||||
[DocRole.Editor, 'editor'],
|
||||
[DocRole.Manager, 'manager'],
|
||||
[DocRole.Owner, 'owner'],
|
||||
]);
|
||||
|
||||
const NATIVE_WORKSPACE_ROLE_TO_LEGACY = new Map<
|
||||
PermissionWorkspaceRole,
|
||||
WorkspaceRole
|
||||
>([
|
||||
['external', WorkspaceRole.External],
|
||||
['member', WorkspaceRole.Collaborator],
|
||||
['admin', WorkspaceRole.Admin],
|
||||
['owner', WorkspaceRole.Owner],
|
||||
]);
|
||||
|
||||
const NATIVE_DOC_ROLE_TO_LEGACY = new Map<PermissionDocRole, DocRole>([
|
||||
['none', DocRole.None],
|
||||
['external', DocRole.External],
|
||||
['reader', DocRole.Reader],
|
||||
['commenter', DocRole.Commenter],
|
||||
['editor', DocRole.Editor],
|
||||
['manager', DocRole.Manager],
|
||||
['owner', DocRole.Owner],
|
||||
]);
|
||||
|
||||
export function toNativeWorkspaceRole(role: WorkspaceRole | null | undefined) {
|
||||
return role == null ? undefined : WORKSPACE_ROLE_TO_NATIVE.get(role);
|
||||
}
|
||||
|
||||
export function toNativeDocRole(role: DocRole | null | undefined) {
|
||||
return role == null ? undefined : DOC_ROLE_TO_NATIVE.get(role);
|
||||
}
|
||||
|
||||
export function toNativeExplicitDocGrantRole(role: DocRole | null | undefined) {
|
||||
if (role === DocRole.None || role === DocRole.External) {
|
||||
return undefined;
|
||||
}
|
||||
return toNativeDocRole(role);
|
||||
}
|
||||
|
||||
export function toNativeMemberState(status?: WorkspaceMemberStatus | null) {
|
||||
switch (status) {
|
||||
case WorkspaceMemberStatus.Accepted:
|
||||
return 'active';
|
||||
case WorkspaceMemberStatus.UnderReview:
|
||||
return 'waiting_review';
|
||||
case WorkspaceMemberStatus.AllocatingSeat:
|
||||
case WorkspaceMemberStatus.NeedMoreSeat:
|
||||
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
|
||||
return 'waiting_seat';
|
||||
case WorkspaceMemberStatus.Pending:
|
||||
return 'pending';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceLegacyBoundary(
|
||||
workspace: PermissionEvaluationOutputV1['workspace']
|
||||
): PermissionLegacyRoleBoundary {
|
||||
const effectiveRole = workspace.effectiveRole ?? null;
|
||||
return {
|
||||
resourceOwnerRole: workspace.resourceOwnerRole ?? null,
|
||||
effectiveRole,
|
||||
legacyApiRole: effectiveRole
|
||||
? (NATIVE_WORKSPACE_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function docLegacyBoundary(
|
||||
doc: PermissionEvaluationOutputV1['docs'][number]
|
||||
): PermissionLegacyRoleBoundary {
|
||||
const effectiveRole = doc.effectiveRole ?? null;
|
||||
return {
|
||||
resourceOwnerRole: doc.resourceOwnerRole ?? null,
|
||||
effectiveRole,
|
||||
legacyApiRole: effectiveRole
|
||||
? (NATIVE_DOC_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
Resource,
|
||||
ResourceAction,
|
||||
ResourceRole,
|
||||
ResourceType,
|
||||
} from './resource';
|
||||
|
||||
const ACTION_CHECKER_PROVIDERS = new Map<ResourceType, AccessController<any>>();
|
||||
|
||||
function registerAccessController<Type extends ResourceType>(
|
||||
type: Type,
|
||||
provider: AccessController<Type>
|
||||
) {
|
||||
ACTION_CHECKER_PROVIDERS.set(type, provider);
|
||||
}
|
||||
|
||||
export function getAccessController<Type extends ResourceType>(
|
||||
type: Type
|
||||
): AccessController<Type> {
|
||||
const provider = ACTION_CHECKER_PROVIDERS.get(type);
|
||||
if (!provider) {
|
||||
throw new Error(`No action checker provider for type ${type}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export abstract class AccessController<
|
||||
Type extends ResourceType,
|
||||
> implements OnModuleInit {
|
||||
protected abstract readonly type: Type;
|
||||
protected logger = new Logger(AccessController.name);
|
||||
|
||||
onModuleInit() {
|
||||
registerAccessController(this.type, this);
|
||||
}
|
||||
|
||||
abstract assert(
|
||||
resource: Resource<Type>,
|
||||
action: ResourceAction<Type>
|
||||
): Promise<void>;
|
||||
|
||||
abstract can(
|
||||
resource: Resource<Type>,
|
||||
action: ResourceAction<Type>
|
||||
): Promise<boolean>;
|
||||
|
||||
abstract role(resource: Resource<Type>): Promise<{
|
||||
role: ResourceRole<Type> | null;
|
||||
permissions: Record<ResourceAction<Type>, boolean>;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
|
||||
import { metrics } from '../../base';
|
||||
import type { PermissionEvaluationOutputV1 } from '../../native';
|
||||
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
|
||||
import {
|
||||
PermissionContextLoader,
|
||||
type PermissionDocAction,
|
||||
type PermissionWorkspaceAction,
|
||||
} from './context-loader';
|
||||
import { PermissionService } from './service';
|
||||
import { PermissionSqlPredicateBuilder } from './sql-predicate';
|
||||
|
||||
export const PERMISSION_SHADOW_MISMATCH_CATEGORIES = [
|
||||
'legacy_compat_delta',
|
||||
'projection',
|
||||
'rust_rule',
|
||||
'loader',
|
||||
'sql_predicate',
|
||||
'legacy_api_role_mapping',
|
||||
'preview_read_mapping',
|
||||
'runtime_state',
|
||||
'projection_or_loader',
|
||||
] as const;
|
||||
|
||||
type PermissionShadowMismatchCategory =
|
||||
(typeof PERMISSION_SHADOW_MISMATCH_CATEGORIES)[number];
|
||||
|
||||
@Injectable()
|
||||
export class PermissionDiagnosticService {
|
||||
constructor(
|
||||
private readonly loader: PermissionContextLoader,
|
||||
private readonly permission: PermissionService,
|
||||
@Optional()
|
||||
@Inject(PermissionSqlPredicateBuilder)
|
||||
private readonly sqlPredicate = new PermissionSqlPredicateBuilder()
|
||||
) {}
|
||||
|
||||
async shadowDocPermissions(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docs: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||
allowLocal?: boolean;
|
||||
expectedDeltaCategory?: PermissionShadowMismatchCategory;
|
||||
}) {
|
||||
const [legacyOutput, newOutput] = await Promise.all([
|
||||
this.loader.load(input).then(input => this.permission.evaluate(input)),
|
||||
this.loader
|
||||
.loadFromNewTables(input)
|
||||
.then(input => this.permission.evaluate(input)),
|
||||
]);
|
||||
|
||||
const legacy = legacyOutput.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
...docLegacyBoundary(doc),
|
||||
decisions: doc.decisions,
|
||||
}));
|
||||
const current = newOutput.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
...docLegacyBoundary(doc),
|
||||
decisions: doc.decisions,
|
||||
}));
|
||||
const matched = JSON.stringify(legacy) === JSON.stringify(current);
|
||||
const mismatchType = matched
|
||||
? null
|
||||
: (input.expectedDeltaCategory ??
|
||||
this.classifyDocShadowMismatch(legacy, current));
|
||||
this.recordShadowMismatch('doc', mismatchType);
|
||||
|
||||
return {
|
||||
matched,
|
||||
legacy,
|
||||
current,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowWorkspacePermissions(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
actions: PermissionWorkspaceAction[];
|
||||
allowLocal?: boolean;
|
||||
expectedDeltaCategory?: PermissionShadowMismatchCategory;
|
||||
}) {
|
||||
const legacyInput = {
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceActions: input.actions,
|
||||
allowLocal: input.allowLocal,
|
||||
};
|
||||
const [legacyOutput, newOutput] = await Promise.all([
|
||||
this.loader
|
||||
.load(legacyInput)
|
||||
.then(input => this.permission.evaluate(input)),
|
||||
this.loader
|
||||
.loadFromNewTables(legacyInput)
|
||||
.then(input => this.permission.evaluate(input)),
|
||||
]);
|
||||
|
||||
const legacy = {
|
||||
...workspaceLegacyBoundary(legacyOutput.workspace),
|
||||
decisions: legacyOutput.workspace.decisions,
|
||||
};
|
||||
const current = {
|
||||
...workspaceLegacyBoundary(newOutput.workspace),
|
||||
decisions: newOutput.workspace.decisions,
|
||||
};
|
||||
const matched = JSON.stringify(legacy) === JSON.stringify(current);
|
||||
const mismatchType = matched
|
||||
? null
|
||||
: (input.expectedDeltaCategory ??
|
||||
this.classifyShadowMismatch(legacyOutput, newOutput));
|
||||
this.recordShadowMismatch('workspace', mismatchType);
|
||||
|
||||
return {
|
||||
matched,
|
||||
legacy,
|
||||
current,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowSqlDocRead(input: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
docs: Array<{ docId: string }>;
|
||||
sqlReadableDocIds: string[];
|
||||
allowLocal?: boolean;
|
||||
expectedDeltaCategory?: PermissionShadowMismatchCategory;
|
||||
}) {
|
||||
const rustOutput = this.permission.evaluate(
|
||||
await this.loader.loadFromNewTables({
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
docs: input.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
actions: ['Doc.Read'],
|
||||
})),
|
||||
allowLocal: input.allowLocal,
|
||||
})
|
||||
);
|
||||
const rustReadable = new Set(
|
||||
rustOutput.docs
|
||||
.filter(doc => doc.decisions[0]?.allowed)
|
||||
.map(doc => doc.docId)
|
||||
);
|
||||
const sqlReadable = new Set(input.sqlReadableDocIds);
|
||||
const missingInSql = [...rustReadable].filter(id => !sqlReadable.has(id));
|
||||
const extraInSql = [...sqlReadable].filter(id => !rustReadable.has(id));
|
||||
const mismatchType =
|
||||
missingInSql.length || extraInSql.length
|
||||
? (input.expectedDeltaCategory ?? 'sql_predicate')
|
||||
: null;
|
||||
this.recordShadowMismatch('sql_predicate', mismatchType);
|
||||
|
||||
return {
|
||||
matched: mismatchType === null,
|
||||
predicate: this.sqlPredicate.docReadableByNewTables({
|
||||
workspaceId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
action: 'Doc.Read',
|
||||
}),
|
||||
rustReadableDocIds: [...rustReadable],
|
||||
sqlReadableDocIds: [...sqlReadable],
|
||||
missingInSql,
|
||||
extraInSql,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowPreviewDoc(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const result = await this.shadowDocPermissions({
|
||||
...input,
|
||||
docs: [{ docId: input.docId, actions: ['Doc.Preview', 'Doc.Read'] }],
|
||||
});
|
||||
const legacy = result.legacy[0];
|
||||
const current = result.current[0];
|
||||
const legacyPreviewAllowed = legacy?.decisions.find(
|
||||
decision => decision.action === 'Doc.Preview'
|
||||
)?.allowed;
|
||||
const legacyReadAllowed = legacy?.decisions.find(
|
||||
decision => decision.action === 'Doc.Read'
|
||||
)?.allowed;
|
||||
const previewAllowed = current?.decisions.find(
|
||||
decision => decision.action === 'Doc.Preview'
|
||||
)?.allowed;
|
||||
const readAllowed = current?.decisions.find(
|
||||
decision => decision.action === 'Doc.Read'
|
||||
)?.allowed;
|
||||
const mismatchType =
|
||||
legacyPreviewAllowed !== previewAllowed ||
|
||||
(previewAllowed && readAllowed && !legacyReadAllowed)
|
||||
? 'preview_read_mapping'
|
||||
: result.mismatchType;
|
||||
this.recordShadowMismatch('preview', mismatchType);
|
||||
|
||||
return {
|
||||
...result,
|
||||
matched: result.matched && mismatchType === null,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowPreviewWorkspace(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const result = await this.shadowWorkspacePermissions({
|
||||
...input,
|
||||
actions: ['Workspace.Preview', 'Workspace.Read'],
|
||||
});
|
||||
const legacyPreviewAllowed = result.legacy.decisions.find(
|
||||
decision => decision.action === 'Workspace.Preview'
|
||||
)?.allowed;
|
||||
const legacyReadAllowed = result.legacy.decisions.find(
|
||||
decision => decision.action === 'Workspace.Read'
|
||||
)?.allowed;
|
||||
const previewAllowed = result.current.decisions.find(
|
||||
decision => decision.action === 'Workspace.Preview'
|
||||
)?.allowed;
|
||||
const readAllowed = result.current.decisions.find(
|
||||
decision => decision.action === 'Workspace.Read'
|
||||
)?.allowed;
|
||||
const mismatchType =
|
||||
legacyPreviewAllowed !== previewAllowed ||
|
||||
(previewAllowed && readAllowed && !legacyReadAllowed)
|
||||
? 'preview_read_mapping'
|
||||
: result.mismatchType;
|
||||
this.recordShadowMismatch('preview', mismatchType);
|
||||
|
||||
return {
|
||||
...result,
|
||||
matched: result.matched && mismatchType === null,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
private classifyShadowMismatch(
|
||||
legacyOutput: PermissionEvaluationOutputV1,
|
||||
newOutput: PermissionEvaluationOutputV1
|
||||
) {
|
||||
if (JSON.stringify(legacyOutput) === JSON.stringify(newOutput)) {
|
||||
return null;
|
||||
}
|
||||
const legacyRestrictions =
|
||||
JSON.stringify(legacyOutput).includes('runtime_');
|
||||
const newRestrictions = JSON.stringify(newOutput).includes('runtime_');
|
||||
if (legacyRestrictions || newRestrictions) {
|
||||
return 'runtime_state';
|
||||
}
|
||||
if (legacyOutput.docs.length !== newOutput.docs.length) {
|
||||
return 'loader';
|
||||
}
|
||||
if (JSON.stringify(legacyOutput.docs) !== JSON.stringify(newOutput.docs)) {
|
||||
return 'rust_rule';
|
||||
}
|
||||
return 'projection';
|
||||
}
|
||||
|
||||
private classifyDocShadowMismatch(
|
||||
legacy: Array<
|
||||
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
|
||||
>,
|
||||
current: Array<
|
||||
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
|
||||
>
|
||||
) {
|
||||
if (JSON.stringify(legacy) === JSON.stringify(current)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const legacyApi = legacy.map(doc => ({
|
||||
effectiveRole: doc.effectiveRole,
|
||||
legacyApiRole: doc.legacyApiRole,
|
||||
resourceOwnerRole: doc.resourceOwnerRole,
|
||||
}));
|
||||
const currentApi = current.map(doc => ({
|
||||
effectiveRole: doc.effectiveRole,
|
||||
legacyApiRole: doc.legacyApiRole,
|
||||
resourceOwnerRole: doc.resourceOwnerRole,
|
||||
}));
|
||||
if (JSON.stringify(legacyApi) !== JSON.stringify(currentApi)) {
|
||||
return 'legacy_api_role_mapping';
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(legacy).includes('runtime_') ||
|
||||
JSON.stringify(current).includes('runtime_')
|
||||
) {
|
||||
return 'runtime_state';
|
||||
}
|
||||
|
||||
if (legacy.length !== current.length) {
|
||||
return 'loader';
|
||||
}
|
||||
|
||||
const legacyDecisions = legacy.map(doc => doc.decisions);
|
||||
const currentDecisions = current.map(doc => doc.decisions);
|
||||
if (JSON.stringify(legacyDecisions) !== JSON.stringify(currentDecisions)) {
|
||||
return 'rust_rule';
|
||||
}
|
||||
|
||||
return 'projection';
|
||||
}
|
||||
|
||||
private recordShadowMismatch(
|
||||
scope: string,
|
||||
category: PermissionShadowMismatchCategory | null
|
||||
) {
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.permission
|
||||
.counter('shadow_mismatches', {
|
||||
description: 'Permission shadow-read mismatch count',
|
||||
})
|
||||
.add(1, { scope, category });
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DocActionDenied } from '../../base';
|
||||
import { AccessController, getAccessController } from './controller';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
DocAction,
|
||||
docActionRequiredRole,
|
||||
DocRole,
|
||||
mapDocRoleToPermissions,
|
||||
} from './types';
|
||||
import { WorkspaceAccessController } from './workspace';
|
||||
|
||||
@Injectable()
|
||||
export class DocAccessController extends AccessController<'doc'> {
|
||||
protected readonly type = 'doc';
|
||||
constructor(private readonly policy: WorkspacePolicyService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async role(resource: Resource<'doc'>) {
|
||||
const role = await this.getRole(resource);
|
||||
const permissions = await this.policy.applyDocPermissions(
|
||||
resource.workspaceId,
|
||||
mapDocRoleToPermissions(role)
|
||||
);
|
||||
const sharingAllowed = await this.policy.canPublishDoc(
|
||||
resource.workspaceId
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
permissions['Doc.Publish'] = false;
|
||||
}
|
||||
|
||||
return { role, permissions };
|
||||
}
|
||||
|
||||
async can(resource: Resource<'doc'>, action: DocAction) {
|
||||
const { permissions, role } = await this.role(resource);
|
||||
const allow = permissions[action] || false;
|
||||
|
||||
if (!allow) {
|
||||
this.logger.debug('Doc access check failed', {
|
||||
action,
|
||||
resource,
|
||||
role,
|
||||
requiredRole: docActionRequiredRole(action),
|
||||
});
|
||||
}
|
||||
|
||||
return allow;
|
||||
}
|
||||
|
||||
async assert(resource: Resource<'doc'>, action: DocAction) {
|
||||
const allow = await this.can(resource, action);
|
||||
|
||||
if (!allow) {
|
||||
throw new DocActionDenied({
|
||||
docId: resource.docId,
|
||||
spaceId: resource.workspaceId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getRole(payload: Resource<'doc'>): Promise<DocRole | null> {
|
||||
const workspaceController = getAccessController(
|
||||
'ws'
|
||||
) as WorkspaceAccessController;
|
||||
const docRoles = await workspaceController.getDocRoles(payload, [
|
||||
payload.docId,
|
||||
]);
|
||||
return docRoles[0];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,54 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QuotaServiceModule } from '../quota/service.module';
|
||||
import { AccessControllerBuilder } from './builder';
|
||||
import { DocAccessController } from './doc';
|
||||
import { PermissionContextLoader } from './context-loader';
|
||||
import { PermissionDiagnosticService } from './diagnostic';
|
||||
import { EventsListener } from './event';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import { WorkspaceAccessController } from './workspace';
|
||||
import { PermissionProjectionChecker } from './projection-checker';
|
||||
import { PermissionService } from './service';
|
||||
import { PermissionSqlPredicateBuilder } from './sql-predicate';
|
||||
|
||||
@Module({
|
||||
imports: [QuotaServiceModule],
|
||||
providers: [
|
||||
WorkspaceAccessController,
|
||||
DocAccessController,
|
||||
AccessControllerBuilder,
|
||||
EventsListener,
|
||||
WorkspacePolicyService,
|
||||
PermissionProjectionChecker,
|
||||
PermissionSqlPredicateBuilder,
|
||||
PermissionContextLoader,
|
||||
PermissionDiagnosticService,
|
||||
PermissionService,
|
||||
],
|
||||
exports: [
|
||||
AccessControllerBuilder,
|
||||
WorkspacePolicyService,
|
||||
PermissionProjectionChecker,
|
||||
PermissionSqlPredicateBuilder,
|
||||
PermissionDiagnosticService,
|
||||
PermissionService,
|
||||
],
|
||||
exports: [AccessControllerBuilder, WorkspacePolicyService],
|
||||
})
|
||||
export class PermissionModule {}
|
||||
|
||||
export { AccessControllerBuilder as AccessController } from './builder';
|
||||
export { AccessControllerBuilder as PermissionAccess } from './builder';
|
||||
export { PermissionContextLoader } from './context-loader';
|
||||
export {
|
||||
PERMISSION_SHADOW_MISMATCH_CATEGORIES,
|
||||
PermissionDiagnosticService,
|
||||
} from './diagnostic';
|
||||
export {
|
||||
type DotToUnderline,
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
} from './permission-map';
|
||||
export { WorkspacePolicyService } from './policy';
|
||||
export { PermissionProjectionChecker } from './projection-checker';
|
||||
export { PermissionService } from './service';
|
||||
export { PermissionSqlPredicateBuilder } from './sql-predicate';
|
||||
export {
|
||||
DOC_ACTIONS,
|
||||
type DocAction,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type DotToUnderline<T extends string> =
|
||||
T extends `${infer Prefix}.${infer Suffix}`
|
||||
? `${Prefix}_${DotToUnderline<Suffix>}`
|
||||
: T;
|
||||
|
||||
export function mapPermissionsToGraphqlPermissions<A extends string>(
|
||||
permission: Record<A, boolean>
|
||||
): Record<DotToUnderline<A>, boolean> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(permission).map(([key, value]) => [
|
||||
key.replaceAll('.', '_'),
|
||||
value,
|
||||
])
|
||||
) as Record<DotToUnderline<A>, boolean>;
|
||||
}
|
||||
@@ -1,29 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
|
||||
import {
|
||||
DocActionDenied,
|
||||
OnEvent,
|
||||
OwnerCanNotLeaveWorkspace,
|
||||
SpaceAccessDenied,
|
||||
} from '../../base';
|
||||
import { OnEvent } from '../../base';
|
||||
import { Models, WorkspaceRole } from '../../models';
|
||||
import { QuotaService } from '../quota/service';
|
||||
import { getAccessController } from './controller';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
type DocAction,
|
||||
type DocActionPermissions,
|
||||
mapWorkspaceRoleToPermissions,
|
||||
type WorkspaceAction,
|
||||
type WorkspaceActionPermissions,
|
||||
} from './types';
|
||||
import { QuotaStateService } from '../quota/state';
|
||||
|
||||
export type WorkspaceReadonlyReason = 'member_overflow' | 'storage_overflow';
|
||||
type WorkspaceQuotaSnapshot = Awaited<
|
||||
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
|
||||
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
|
||||
> & {
|
||||
ownerQuota?: string;
|
||||
readonlyReasons: WorkspaceReadonlyReason[];
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
@@ -35,35 +21,6 @@ export type WorkspaceState = {
|
||||
usesFallbackOwnerQuota: boolean;
|
||||
};
|
||||
|
||||
const READONLY_WORKSPACE_ACTIONS: WorkspaceAction[] = [
|
||||
'Workspace.CreateDoc',
|
||||
'Workspace.Settings.Update',
|
||||
'Workspace.Properties.Create',
|
||||
'Workspace.Properties.Update',
|
||||
'Workspace.Properties.Delete',
|
||||
'Workspace.Blobs.Write',
|
||||
];
|
||||
|
||||
const READONLY_DOC_ACTIONS: DocAction[] = [
|
||||
'Doc.Update',
|
||||
'Doc.Duplicate',
|
||||
'Doc.Publish',
|
||||
'Doc.Comments.Create',
|
||||
'Doc.Comments.Update',
|
||||
'Doc.Comments.Resolve',
|
||||
];
|
||||
|
||||
const READONLY_WORKSPACE_FEATURE =
|
||||
'quota_exceeded_readonly_workspace_v1' as const;
|
||||
|
||||
type WorkspaceRoleChecker = {
|
||||
getRole(resource: Resource<'ws'>): Promise<WorkspaceRole | null>;
|
||||
docRoles(
|
||||
resource: Resource<'ws'>,
|
||||
docIds: string[]
|
||||
): Promise<Array<{ role: unknown; permissions: Record<DocAction, boolean> }>>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'workspace.blobs.updated': {
|
||||
@@ -76,39 +33,23 @@ declare global {
|
||||
export class WorkspacePolicyService {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly quota: QuotaService
|
||||
private readonly quotaState: QuotaStateService
|
||||
) {}
|
||||
|
||||
async getWorkspaceState(workspaceId: string): Promise<WorkspaceState> {
|
||||
const [isTeamWorkspace, isUnlimitedWorkspace, quota] = await Promise.all([
|
||||
this.models.workspace.isTeamWorkspace(workspaceId),
|
||||
this.models.workspaceFeature.has(workspaceId, 'unlimited_workspace'),
|
||||
this.quota.getWorkspaceQuotaWithUsage(workspaceId),
|
||||
]);
|
||||
const quota =
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
const quotaSnapshot = quota as WorkspaceQuotaSnapshot;
|
||||
|
||||
const readonlyReasons: WorkspaceReadonlyReason[] = [];
|
||||
const usesFallbackOwnerQuota =
|
||||
!!quotaSnapshot.ownerQuota && !isUnlimitedWorkspace;
|
||||
|
||||
if (usesFallbackOwnerQuota && quotaSnapshot.overcapacityMemberCount > 0) {
|
||||
readonlyReasons.push('member_overflow');
|
||||
}
|
||||
|
||||
if (
|
||||
usesFallbackOwnerQuota &&
|
||||
quotaSnapshot.usedStorageQuota > quotaSnapshot.storageQuota
|
||||
) {
|
||||
readonlyReasons.push('storage_overflow');
|
||||
}
|
||||
const readonlyReasons = quotaSnapshot.readonlyReasons;
|
||||
|
||||
return {
|
||||
isTeamWorkspace,
|
||||
isTeamWorkspace: ['team', 'selfhost_team'].includes(quotaSnapshot.plan),
|
||||
isReadonly: readonlyReasons.length > 0,
|
||||
readonlyReasons,
|
||||
canRecoverByRemovingMembers: readonlyReasons.includes('member_overflow'),
|
||||
canRecoverByDeletingBlobs: readonlyReasons.includes('storage_overflow'),
|
||||
usesFallbackOwnerQuota,
|
||||
usesFallbackOwnerQuota: quotaSnapshot.usesOwnerQuota,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,286 +67,19 @@ export class WorkspacePolicyService {
|
||||
}
|
||||
|
||||
async reconcileWorkspaceQuotaState(workspaceId: string) {
|
||||
const [state, isReadonlyFeatureEnabled] = await Promise.all([
|
||||
this.getWorkspaceState(workspaceId),
|
||||
this.models.workspaceFeature.has(workspaceId, READONLY_WORKSPACE_FEATURE),
|
||||
]);
|
||||
|
||||
if (state.isReadonly && !isReadonlyFeatureEnabled) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE,
|
||||
`workspace recovery mode: ${state.readonlyReasons.join(',')}`
|
||||
);
|
||||
} else if (!state.isReadonly && isReadonlyFeatureEnabled) {
|
||||
await this.models.workspaceFeature.remove(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
return await this.getWorkspaceState(workspaceId);
|
||||
}
|
||||
|
||||
async isWorkspaceReadonly(workspaceId: string) {
|
||||
const hasReadonlyFeature = await this.models.workspaceFeature.has(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE
|
||||
);
|
||||
|
||||
if (!hasReadonlyFeature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = await this.getWorkspaceState(workspaceId);
|
||||
if (!state.isReadonly) {
|
||||
await this.models.workspaceFeature.remove(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async isSharingEnabled(workspaceId: string) {
|
||||
return await this.models.workspace.allowSharing(workspaceId);
|
||||
}
|
||||
|
||||
async canReadWorkspaceByPublicFlag(workspaceId: string) {
|
||||
const workspace = await this.models.workspace.get(workspaceId);
|
||||
return !!workspace?.public && (workspace.enableSharing ?? true);
|
||||
}
|
||||
|
||||
async canReadWorkspaceBySharedDocs(workspaceId: string) {
|
||||
const [sharingEnabled, hasPublicDocs] = await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.models.doc.hasPublic(workspaceId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && hasPublicDocs;
|
||||
}
|
||||
|
||||
async canReadSharedDoc(workspaceId: string, docId: string) {
|
||||
const [sharingEnabled, isPublicDoc] = await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.models.doc.isPublic(workspaceId, docId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && isPublicDoc;
|
||||
}
|
||||
|
||||
async canPreviewDoc(workspaceId: string, docId: string) {
|
||||
const [sharingEnabled, canReadSharedDoc, allowUrlPreview] =
|
||||
await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.canReadSharedDoc(workspaceId, docId),
|
||||
this.models.workspace.allowUrlPreview(workspaceId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && (canReadSharedDoc || allowUrlPreview);
|
||||
}
|
||||
|
||||
async canPreviewWorkspace(workspaceId: string) {
|
||||
const [sharingEnabled, allowUrlPreview] = await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.models.workspace.allowUrlPreview(workspaceId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && allowUrlPreview;
|
||||
}
|
||||
|
||||
async canPublishDoc(workspaceId: string) {
|
||||
return await this.isSharingEnabled(workspaceId);
|
||||
}
|
||||
|
||||
async applyWorkspacePermissions(
|
||||
workspaceId: string,
|
||||
permissions: WorkspaceActionPermissions
|
||||
) {
|
||||
if (!(await this.isWorkspaceReadonly(workspaceId))) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const next = { ...permissions };
|
||||
READONLY_WORKSPACE_ACTIONS.forEach(action => {
|
||||
next[action] = false;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async applyDocPermissions(
|
||||
workspaceId: string,
|
||||
permissions: DocActionPermissions
|
||||
) {
|
||||
if (!(await this.isWorkspaceReadonly(workspaceId))) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const next = { ...permissions };
|
||||
READONLY_DOC_ACTIONS.forEach(action => {
|
||||
next[action] = false;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async assertWorkspaceActionAllowed(
|
||||
workspaceId: string,
|
||||
action: WorkspaceAction
|
||||
) {
|
||||
if (
|
||||
READONLY_WORKSPACE_ACTIONS.includes(action) &&
|
||||
(await this.isWorkspaceReadonly(workspaceId))
|
||||
) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async assertDocActionAllowed(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
action: DocAction
|
||||
) {
|
||||
if (
|
||||
READONLY_DOC_ACTIONS.includes(action) &&
|
||||
(await this.isWorkspaceReadonly(workspaceId))
|
||||
) {
|
||||
throw new DocActionDenied({
|
||||
action,
|
||||
docId,
|
||||
spaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertWorkspaceRoleAction(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
action: WorkspaceAction
|
||||
) {
|
||||
const checker = getAccessController(
|
||||
'ws'
|
||||
) as unknown as WorkspaceRoleChecker;
|
||||
const role = await checker.getRole({ userId, workspaceId });
|
||||
const permissions = mapWorkspaceRoleToPermissions(role);
|
||||
|
||||
if (!permissions[action]) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async assertDocRoleAction(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
action: DocAction
|
||||
) {
|
||||
const checker = getAccessController(
|
||||
'ws'
|
||||
) as unknown as WorkspaceRoleChecker;
|
||||
const [role] = await checker.docRoles({ userId, workspaceId }, [docId]);
|
||||
|
||||
if (!role?.permissions[action]) {
|
||||
throw new DocActionDenied({
|
||||
action,
|
||||
docId,
|
||||
spaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanUploadBlob(userId: string, workspaceId: string) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
'Workspace.Blobs.Write'
|
||||
);
|
||||
await this.assertWorkspaceActionAllowed(
|
||||
workspaceId,
|
||||
'Workspace.Blobs.Write'
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanDeleteBlob(userId: string, workspaceId: string) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
'Workspace.Blobs.Write'
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanInviteMembers(workspaceId: string) {
|
||||
if (await this.isWorkspaceReadonly(workspaceId)) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanRevokeMember(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
role: WorkspaceRole
|
||||
) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
role === WorkspaceRole.Admin
|
||||
? 'Workspace.Administrators.Manage'
|
||||
: 'Workspace.Users.Manage'
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async handleTeamPlanCanceled(workspaceId: string) {
|
||||
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
|
||||
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
|
||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
||||
await this.cleanupTeamPlanCanceled(workspaceId);
|
||||
return await this.reconcileWorkspaceQuotaState(workspaceId);
|
||||
}
|
||||
|
||||
async assertCanUnpublishDoc(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
) {
|
||||
await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish');
|
||||
}
|
||||
|
||||
async assertCanPublishDoc(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
) {
|
||||
await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish');
|
||||
await this.assertDocActionAllowed(workspaceId, docId, 'Doc.Publish');
|
||||
|
||||
if (!(await this.canPublishDoc(workspaceId))) {
|
||||
throw new DocActionDenied({
|
||||
action: 'Doc.Publish',
|
||||
docId,
|
||||
spaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanManageInviteLink(userId: string, workspaceId: string) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
'Workspace.Users.Manage'
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanLeaveWorkspace(userId: string, workspaceId: string) {
|
||||
const role = await this.models.workspaceUser.getActive(workspaceId, userId);
|
||||
|
||||
if (!role) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
|
||||
if (role.type === WorkspaceRole.Owner) {
|
||||
throw new OwnerCanNotLeaveWorkspace();
|
||||
}
|
||||
@Transactional()
|
||||
private async cleanupTeamPlanCanceled(workspaceId: string) {
|
||||
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
|
||||
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
|
||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated')
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Models } from '../../models';
|
||||
import {
|
||||
PermissionContextLoader,
|
||||
type PermissionDocAction,
|
||||
type PermissionWorkspaceAction,
|
||||
} from './context-loader';
|
||||
import { PermissionService } from './service';
|
||||
|
||||
type ProjectionDecisionSample = {
|
||||
category: string;
|
||||
workspaceId: string;
|
||||
docId: string | null;
|
||||
userId: string | null;
|
||||
workspaceActions: string[] | null;
|
||||
docActions: string[] | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PermissionProjectionChecker {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly models: Models,
|
||||
@Optional()
|
||||
private readonly loader?: PermissionContextLoader,
|
||||
@Optional()
|
||||
private readonly permission?: PermissionService
|
||||
) {}
|
||||
|
||||
async checkLegacyProjection() {
|
||||
const report =
|
||||
await this.models.permissionProjection.checkLegacyProjection();
|
||||
return {
|
||||
...report,
|
||||
oldNewDecisionMismatch: await this.checkOldNewLoaderDecisionMismatch(),
|
||||
};
|
||||
}
|
||||
|
||||
private async checkOldNewLoaderDecisionMismatch() {
|
||||
const { loader, permission } = this;
|
||||
if (!loader || !permission) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const samples = await this.db.$queryRaw<ProjectionDecisionSample[]>`
|
||||
(
|
||||
SELECT
|
||||
'active_member_doc' AS category,
|
||||
old_member.workspace_id AS "workspaceId",
|
||||
old_doc.page_id AS "docId",
|
||||
old_member.user_id AS "userId",
|
||||
NULL::text[] AS "workspaceActions",
|
||||
ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions"
|
||||
FROM workspace_user_permissions old_member
|
||||
INNER JOIN workspace_pages old_doc
|
||||
ON old_doc.workspace_id = old_member.workspace_id
|
||||
WHERE old_member.status = 'Accepted'::"WorkspaceMemberStatus"
|
||||
AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL
|
||||
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
|
||||
ORDER BY md5(old_member.workspace_id || ':' || old_doc.page_id || ':' || old_member.user_id)
|
||||
LIMIT 80
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
'workspace_invitation' AS category,
|
||||
old_member.workspace_id AS "workspaceId",
|
||||
NULL::text AS "docId",
|
||||
old_member.user_id AS "userId",
|
||||
ARRAY['Workspace.Read']::text[] AS "workspaceActions",
|
||||
NULL::text[] AS "docActions"
|
||||
FROM workspace_user_permissions old_member
|
||||
WHERE old_member.status <> 'Accepted'::"WorkspaceMemberStatus"
|
||||
AND affine_permission_workspace_invitation_state(old_member.status) IS NOT NULL
|
||||
AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL
|
||||
ORDER BY md5(old_member.workspace_id || ':' || old_member.user_id)
|
||||
LIMIT 40
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
'public_doc_anonymous' AS category,
|
||||
old_doc.workspace_id AS "workspaceId",
|
||||
old_doc.page_id AS "docId",
|
||||
NULL::text AS "userId",
|
||||
NULL::text[] AS "workspaceActions",
|
||||
ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions"
|
||||
FROM workspace_pages old_doc
|
||||
WHERE old_doc.public
|
||||
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
|
||||
ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id)
|
||||
LIMIT 40
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
'workspace_url_preview_private_doc' AS category,
|
||||
old_doc.workspace_id AS "workspaceId",
|
||||
old_doc.page_id AS "docId",
|
||||
NULL::text AS "userId",
|
||||
NULL::text[] AS "workspaceActions",
|
||||
ARRAY['Doc.Preview', 'Doc.Read']::text[] AS "docActions"
|
||||
FROM workspace_pages old_doc
|
||||
INNER JOIN workspaces old_workspace
|
||||
ON old_workspace.id = old_doc.workspace_id
|
||||
WHERE old_workspace.enable_sharing
|
||||
AND old_workspace.enable_url_preview
|
||||
AND NOT old_doc.public
|
||||
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
|
||||
ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id)
|
||||
LIMIT 40
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
'explicit_doc_grant' AS category,
|
||||
old_grant.workspace_id AS "workspaceId",
|
||||
old_grant.page_id AS "docId",
|
||||
old_grant.user_id AS "userId",
|
||||
NULL::text[] AS "workspaceActions",
|
||||
ARRAY['Doc.Read', 'Doc.Update', 'Doc.Users.Manage', 'Doc.TransferOwner']::text[] AS "docActions"
|
||||
FROM workspace_page_user_permissions old_grant
|
||||
WHERE affine_permission_legacy_doc_role(old_grant.type) IS NOT NULL
|
||||
ORDER BY md5(old_grant.workspace_id || ':' || old_grant.page_id || ':' || old_grant.user_id)
|
||||
LIMIT 80
|
||||
)
|
||||
`;
|
||||
|
||||
let mismatches = 0;
|
||||
for (const sample of samples) {
|
||||
const input = {
|
||||
userId: sample.userId ?? undefined,
|
||||
workspaceId: sample.workspaceId,
|
||||
workspaceActions: sample.workspaceActions as
|
||||
| PermissionWorkspaceAction[]
|
||||
| undefined,
|
||||
docs:
|
||||
sample.docId && sample.docActions
|
||||
? [
|
||||
{
|
||||
docId: sample.docId,
|
||||
actions: sample.docActions as PermissionDocAction[],
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
const [legacy, projection] = await Promise.all([
|
||||
loader.load(input).then(input => permission.evaluate(input)),
|
||||
loader
|
||||
.loadFromNewTables(input)
|
||||
.then(input => permission.evaluate(input)),
|
||||
]);
|
||||
if (
|
||||
JSON.stringify(legacy.workspace) !==
|
||||
JSON.stringify(projection.workspace) ||
|
||||
JSON.stringify(legacy.docs) !== JSON.stringify(projection.docs)
|
||||
) {
|
||||
mismatches += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
Config,
|
||||
DocActionDenied,
|
||||
InternalServerError,
|
||||
metrics,
|
||||
SpaceAccessDenied,
|
||||
} from '../../base';
|
||||
import {
|
||||
evaluatePermissionV1,
|
||||
type PermissionEvaluationInputV1,
|
||||
type PermissionEvaluationOutputV1,
|
||||
} from '../../native';
|
||||
import { PermissionReadModel } from './config';
|
||||
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
|
||||
import {
|
||||
PermissionContextLoader,
|
||||
type PermissionDocAction,
|
||||
type PermissionWorkspaceAction,
|
||||
} from './context-loader';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import { PermissionSqlPredicateBuilder } from './sql-predicate';
|
||||
import type { DocAction } from './types';
|
||||
|
||||
const RUNTIME_RESTRICTED_WORKSPACE_ACTIONS = new Set<PermissionWorkspaceAction>(
|
||||
[
|
||||
'Workspace.Sync',
|
||||
'Workspace.CreateDoc',
|
||||
'Workspace.Delete',
|
||||
'Workspace.TransferOwner',
|
||||
'Workspace.Users.Manage',
|
||||
'Workspace.Administrators.Manage',
|
||||
'Workspace.Settings.Update',
|
||||
'Workspace.Properties.Create',
|
||||
'Workspace.Properties.Update',
|
||||
'Workspace.Properties.Delete',
|
||||
'Workspace.Blobs.Write',
|
||||
'Workspace.Payment.Manage',
|
||||
]
|
||||
);
|
||||
|
||||
const RUNTIME_RESTRICTED_DOC_ACTIONS = new Set<PermissionDocAction>([
|
||||
'Doc.Duplicate',
|
||||
'Doc.Trash',
|
||||
'Doc.Restore',
|
||||
'Doc.Delete',
|
||||
'Doc.Update',
|
||||
'Doc.Publish',
|
||||
'Doc.TransferOwner',
|
||||
'Doc.Properties.Update',
|
||||
'Doc.Users.Manage',
|
||||
'Doc.Comments.Create',
|
||||
'Doc.Comments.Update',
|
||||
'Doc.Comments.Delete',
|
||||
'Doc.Comments.Resolve',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(
|
||||
private readonly loader: PermissionContextLoader,
|
||||
@Optional()
|
||||
@Inject(PermissionSqlPredicateBuilder)
|
||||
private readonly sqlPredicate = new PermissionSqlPredicateBuilder(),
|
||||
@Optional()
|
||||
private readonly workspacePolicy?: WorkspacePolicyService,
|
||||
@Optional()
|
||||
private readonly config?: Config
|
||||
) {}
|
||||
|
||||
readModel() {
|
||||
return this.config?.permission.readModel ?? PermissionReadModel.Projection;
|
||||
}
|
||||
|
||||
docReadableSqlPredicate(input: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
action: DocAction;
|
||||
docIdColumn?: Prisma.Sql;
|
||||
}) {
|
||||
if (this.readModel() === PermissionReadModel.Projection) {
|
||||
return this.sqlPredicate.docReadableByNewTablesSql(input);
|
||||
}
|
||||
|
||||
return this.sqlPredicate.docReadableByLegacyTablesSql(input);
|
||||
}
|
||||
|
||||
fallbackDocReadableSqlPredicate(input: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
action: DocAction;
|
||||
docIdColumn?: Prisma.Sql;
|
||||
}) {
|
||||
if (
|
||||
this.readModel() === PermissionReadModel.Projection &&
|
||||
(this.config?.permission.fallbackLegacyLoader ?? false)
|
||||
) {
|
||||
return this.sqlPredicate.docReadableByLegacyTablesSql(input);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
evaluate(input: PermissionEvaluationInputV1) {
|
||||
try {
|
||||
return evaluatePermissionV1(input);
|
||||
} catch (error) {
|
||||
throw new InternalServerError(
|
||||
error instanceof Error ? error.message : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async workspacePermissions(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
actions: PermissionWorkspaceAction[];
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const output = await this.evaluateLoaded({
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceActions: input.actions,
|
||||
allowLocal: input.allowLocal,
|
||||
});
|
||||
return {
|
||||
...workspaceLegacyBoundary(output.workspace),
|
||||
decisions: output.workspace.decisions,
|
||||
};
|
||||
}
|
||||
|
||||
async canWorkspace(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
action: PermissionWorkspaceAction;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const output = await this.workspacePermissions({
|
||||
...input,
|
||||
actions: [input.action],
|
||||
});
|
||||
return output.decisions[0]?.allowed ?? false;
|
||||
}
|
||||
|
||||
async assertWorkspace(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
action: PermissionWorkspaceAction;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
if (!(await this.canWorkspace(input))) {
|
||||
throw new SpaceAccessDenied({ spaceId: input.workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async docPermissions(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
actions: PermissionDocAction[];
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const output = await this.evaluateLoaded({
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
docs: [{ docId: input.docId, actions: input.actions }],
|
||||
allowLocal: input.allowLocal,
|
||||
});
|
||||
const doc = output.docs[0];
|
||||
return {
|
||||
...docLegacyBoundary(doc),
|
||||
decisions: doc.decisions,
|
||||
};
|
||||
}
|
||||
|
||||
async canDoc(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
action: PermissionDocAction;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const output = await this.docPermissions({
|
||||
...input,
|
||||
actions: [input.action],
|
||||
});
|
||||
return output.decisions[0]?.allowed ?? false;
|
||||
}
|
||||
|
||||
async assertDoc(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
action: PermissionDocAction;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
if (!(await this.canDoc(input))) {
|
||||
throw new DocActionDenied({
|
||||
action: input.action,
|
||||
docId: input.docId,
|
||||
spaceId: input.workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async filterReadableDocs<T extends { docId: string }>(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docs: T[];
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const decisions = await this.batchDocPermissions({
|
||||
...input,
|
||||
docs: input.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
actions: ['Doc.Read'],
|
||||
})),
|
||||
});
|
||||
const readableDocIds = new Set(
|
||||
decisions.filter(doc => doc.decisions[0]?.allowed).map(doc => doc.docId)
|
||||
);
|
||||
return input.docs.filter(doc => readableDocIds.has(doc.docId));
|
||||
}
|
||||
|
||||
async batchDocPermissions(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docs: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const output = await this.evaluateLoaded(input);
|
||||
return output.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
...docLegacyBoundary(doc),
|
||||
decisions: doc.decisions,
|
||||
}));
|
||||
}
|
||||
|
||||
async canPreviewWorkspace(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
return await this.canWorkspace({
|
||||
...input,
|
||||
action: 'Workspace.Preview',
|
||||
});
|
||||
}
|
||||
|
||||
async canPreviewDoc(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
return await this.canDoc({
|
||||
...input,
|
||||
action: 'Doc.Preview',
|
||||
});
|
||||
}
|
||||
|
||||
private async evaluateLoaded(
|
||||
input: Parameters<PermissionContextLoader['load']>[0]
|
||||
) {
|
||||
if (this.readModel() === PermissionReadModel.Projection) {
|
||||
try {
|
||||
if (
|
||||
this.needsFreshRuntimeState(input) &&
|
||||
(await this.loader.workspaceExists(input.workspaceId))
|
||||
) {
|
||||
await this.workspacePolicy?.getWorkspaceState(input.workspaceId);
|
||||
this.loader.invalidateWorkspaceQuotaRuntime(input.workspaceId);
|
||||
}
|
||||
return this.evaluate(await this.loader.loadFromNewTables(input));
|
||||
} catch (error) {
|
||||
if (
|
||||
input.allowLocal &&
|
||||
error instanceof Error &&
|
||||
error.message === 'Workspace owner not found'
|
||||
) {
|
||||
const loaded = await this.loader.loadFromNewTables(input);
|
||||
if (loaded.workspace?.local) {
|
||||
return this.evaluate(loaded);
|
||||
}
|
||||
}
|
||||
if (!(this.config?.permission.fallbackLegacyLoader ?? false)) {
|
||||
throw error;
|
||||
}
|
||||
metrics.permission
|
||||
.counter('projection_loader_fallbacks', {
|
||||
description: 'Permission projection loader fallback count',
|
||||
})
|
||||
.add(1);
|
||||
}
|
||||
}
|
||||
|
||||
return this.evaluate(await this.loader.load(input));
|
||||
}
|
||||
|
||||
private needsFreshRuntimeState(
|
||||
input: Parameters<PermissionContextLoader['load']>[0]
|
||||
) {
|
||||
return (
|
||||
input.workspaceActions?.some(action =>
|
||||
RUNTIME_RESTRICTED_WORKSPACE_ACTIONS.has(action)
|
||||
) ||
|
||||
input.docs?.some(doc =>
|
||||
doc.actions.some(action => RUNTIME_RESTRICTED_DOC_ACTIONS.has(action))
|
||||
) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionServiceEvaluationOutput = PermissionEvaluationOutputV1;
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { permissionActionRoleMatrixV1 } from '../../native';
|
||||
import { type DocAction, DocRole, WorkspaceRole } from './types';
|
||||
|
||||
export type PermissionSqlPredicate = {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
type RawDocIdColumn = 'doc_id' | 'docs.id';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionSqlPredicateBuilder {
|
||||
private readonly matrix = permissionActionRoleMatrixV1() as {
|
||||
doc?: { roles?: Record<string, string[]> };
|
||||
workspace?: { roles?: Record<string, string[]> };
|
||||
};
|
||||
|
||||
private readonly legacyDocRoleValues = new Map<string, DocRole>([
|
||||
['external', DocRole.External],
|
||||
['reader', DocRole.Reader],
|
||||
['commenter', DocRole.Commenter],
|
||||
['editor', DocRole.Editor],
|
||||
['manager', DocRole.Manager],
|
||||
['owner', DocRole.Owner],
|
||||
]);
|
||||
|
||||
private readonly legacyWorkspaceRoleValues = new Map<string, number>([
|
||||
['member', WorkspaceRole.Collaborator],
|
||||
['admin', WorkspaceRole.Admin],
|
||||
['owner', WorkspaceRole.Owner],
|
||||
]);
|
||||
|
||||
private docRolesForAction(action: DocAction) {
|
||||
return Object.entries(this.matrix.doc?.roles ?? {})
|
||||
.filter(([, actions]) => actions.includes(action))
|
||||
.map(([role]) => role)
|
||||
.filter(role => role !== 'none');
|
||||
}
|
||||
|
||||
private inheritedWorkspaceRolesForDocAction(action: DocAction) {
|
||||
const docRoles = new Set(this.docRolesForAction(action));
|
||||
return [
|
||||
docRoles.has('owner') ? 'owner' : null,
|
||||
docRoles.has('manager') ? 'admin' : null,
|
||||
].filter((role): role is string => role !== null);
|
||||
}
|
||||
|
||||
private nonMemberDocGrantRolesForAction(action: DocAction) {
|
||||
const roles = new Set(this.docRolesForAction(action));
|
||||
roles.delete('external');
|
||||
roles.delete('manager');
|
||||
roles.delete('owner');
|
||||
if (roles.has('editor')) {
|
||||
roles.add('manager');
|
||||
roles.add('owner');
|
||||
}
|
||||
return [...roles];
|
||||
}
|
||||
|
||||
private legacyNonMemberDocGrantRolesForAction(action: DocAction) {
|
||||
return this.nonMemberDocGrantRolesForAction(action)
|
||||
.map(role => this.legacyDocRoleValues.get(role))
|
||||
.filter(role => role !== undefined);
|
||||
}
|
||||
|
||||
private rawDocIdColumn(column: RawDocIdColumn = 'doc_id') {
|
||||
switch (column) {
|
||||
case 'doc_id':
|
||||
case 'docs.id':
|
||||
return column;
|
||||
default:
|
||||
throw new Error(`Unsupported doc id column: ${column}`);
|
||||
}
|
||||
}
|
||||
|
||||
docReadableByLegacyTables(input: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
action: DocAction;
|
||||
docIdColumn?: RawDocIdColumn;
|
||||
}): PermissionSqlPredicate {
|
||||
const roles = this.docRolesForAction(input.action)
|
||||
.map(role => this.legacyDocRoleValues.get(role))
|
||||
.filter(role => role !== undefined);
|
||||
const grantRoles = roles.filter(role => role !== DocRole.External);
|
||||
const nonMemberGrantRoles = this.legacyNonMemberDocGrantRolesForAction(
|
||||
input.action
|
||||
);
|
||||
const legacyActiveMemberRoles = [
|
||||
WorkspaceRole.Collaborator,
|
||||
WorkspaceRole.Admin,
|
||||
WorkspaceRole.Owner,
|
||||
];
|
||||
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
|
||||
input.action
|
||||
)
|
||||
.map(role => this.legacyWorkspaceRoleValues.get(role))
|
||||
.filter(role => role !== undefined);
|
||||
const docIdColumn = this.rawDocIdColumn(input.docIdColumn);
|
||||
|
||||
return {
|
||||
sql: [
|
||||
`EXISTS (SELECT 1 FROM workspaces w`,
|
||||
`LEFT JOIN workspace_pages wp ON wp.workspace_id = w.id`,
|
||||
`AND wp.page_id = ${docIdColumn}`,
|
||||
`LEFT JOIN workspace_user_permissions wup ON wup.workspace_id = w.id`,
|
||||
`AND wup.user_id = ? AND wup.status = 'Accepted'`,
|
||||
`LEFT JOIN workspace_page_user_permissions p ON p.workspace_id = w.id`,
|
||||
`AND p.user_id = ? AND p.page_id = ${docIdColumn}`,
|
||||
`WHERE w.id = ? AND (`,
|
||||
`(wup.type = ANY(?::smallint[]) AND p.type = ANY(?::smallint[]))`,
|
||||
`OR ((wup.id IS NULL OR wup.type <> ALL(?::smallint[])) AND w.enable_sharing AND p.type = ANY(?::smallint[]))`,
|
||||
`OR wup.type = ANY(?::smallint[])`,
|
||||
`OR (wup.type = ANY(?::smallint[]) AND (p.user_id IS NULL OR p.type IN (?, ?))`,
|
||||
`AND COALESCE(wp."defaultRole", 30) = ANY(?::smallint[]))`,
|
||||
`OR (w.enable_sharing AND wp.public AND ? = ANY(?::smallint[]))`,
|
||||
`))`,
|
||||
].join(' '),
|
||||
params: [
|
||||
input.userId,
|
||||
input.userId,
|
||||
input.workspaceId,
|
||||
legacyActiveMemberRoles,
|
||||
grantRoles,
|
||||
legacyActiveMemberRoles,
|
||||
nonMemberGrantRoles,
|
||||
inheritedWorkspaceRoles,
|
||||
legacyActiveMemberRoles,
|
||||
DocRole.None,
|
||||
DocRole.External,
|
||||
grantRoles,
|
||||
DocRole.External,
|
||||
roles,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
docReadableByLegacyTablesSql(input: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
action: DocAction;
|
||||
docIdColumn?: Prisma.Sql;
|
||||
}): Prisma.Sql {
|
||||
const docRoles = this.docRolesForAction(input.action);
|
||||
const legacyDocRoles = docRoles
|
||||
.map(role => this.legacyDocRoleValues.get(role))
|
||||
.filter(role => role !== undefined);
|
||||
const legacyGrantRoles = legacyDocRoles.filter(
|
||||
role => role !== DocRole.External
|
||||
);
|
||||
const legacyNonMemberGrantRoles =
|
||||
this.legacyNonMemberDocGrantRolesForAction(input.action);
|
||||
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
|
||||
input.action
|
||||
)
|
||||
.map(role => this.legacyWorkspaceRoleValues.get(role))
|
||||
.filter(role => role !== undefined);
|
||||
const legacyActiveMemberRoles = [
|
||||
WorkspaceRole.Collaborator,
|
||||
WorkspaceRole.Admin,
|
||||
WorkspaceRole.Owner,
|
||||
];
|
||||
const docIdColumn = input.docIdColumn ?? Prisma.raw('doc_id');
|
||||
|
||||
return Prisma.sql`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM workspaces w
|
||||
LEFT JOIN workspace_pages wp
|
||||
ON wp.workspace_id = w.id
|
||||
AND wp.page_id = ${docIdColumn}
|
||||
LEFT JOIN workspace_user_permissions wup
|
||||
ON wup.workspace_id = w.id
|
||||
AND wup.user_id = ${input.userId}
|
||||
AND wup.status = 'Accepted'::"WorkspaceMemberStatus"
|
||||
LEFT JOIN workspace_page_user_permissions p
|
||||
ON p.workspace_id = w.id
|
||||
AND p.page_id = ${docIdColumn}
|
||||
AND p.user_id = ${input.userId}
|
||||
WHERE w.id = ${input.workspaceId}
|
||||
AND (
|
||||
(
|
||||
wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
|
||||
AND p.type = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`})
|
||||
)
|
||||
OR (
|
||||
(
|
||||
wup.id IS NULL
|
||||
OR wup.type <> ALL(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
|
||||
)
|
||||
AND w.enable_sharing
|
||||
AND p.type = ANY(${Prisma.sql`${legacyNonMemberGrantRoles}::smallint[]`})
|
||||
)
|
||||
OR wup.type = ANY(${Prisma.sql`${inheritedWorkspaceRoles}::smallint[]`})
|
||||
OR (
|
||||
wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
|
||||
AND (
|
||||
p.user_id IS NULL
|
||||
OR p.type IN (${DocRole.None}, ${DocRole.External})
|
||||
)
|
||||
AND COALESCE(wp."defaultRole", 30) = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`})
|
||||
)
|
||||
OR (
|
||||
w.enable_sharing
|
||||
AND wp.public
|
||||
AND 0 = ANY(${Prisma.sql`${legacyDocRoles}::smallint[]`})
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
docReadableByNewTables(input: {
|
||||
workspaceId: string;
|
||||
userId?: string;
|
||||
action: DocAction;
|
||||
docIdColumn?: RawDocIdColumn;
|
||||
}): PermissionSqlPredicate {
|
||||
const docRoles = this.docRolesForAction(input.action);
|
||||
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
|
||||
input.action
|
||||
);
|
||||
const grantRoles = docRoles.filter(role => role !== 'external');
|
||||
const nonMemberGrantRoles = this.nonMemberDocGrantRolesForAction(
|
||||
input.action
|
||||
);
|
||||
const docIdColumn = this.rawDocIdColumn(input.docIdColumn);
|
||||
|
||||
return {
|
||||
sql: [
|
||||
`EXISTS (SELECT 1 FROM workspace_access_policies wap`,
|
||||
`LEFT JOIN doc_access_policies dap ON dap.workspace_id = wap.workspace_id`,
|
||||
`AND dap.doc_id = ${docIdColumn}`,
|
||||
`LEFT JOIN workspace_members wm ON wm.workspace_id = wap.workspace_id`,
|
||||
`AND wm.user_id = ? AND wm.state = 'active'`,
|
||||
`LEFT JOIN doc_grants dg ON dg.workspace_id = wap.workspace_id`,
|
||||
`AND dg.doc_id = ${docIdColumn} AND dg.principal_type = 'user' AND dg.principal_id = ?`,
|
||||
`WHERE wap.workspace_id = ?`,
|
||||
`AND (`,
|
||||
`(wm.id IS NOT NULL AND dg.role = ANY(?::text[]))`,
|
||||
`OR (wm.id IS NULL AND wap.sharing_enabled AND dg.role = ANY(?::text[]))`,
|
||||
`OR wm.role = ANY(?::text[])`,
|
||||
`OR (wm.id IS NOT NULL AND dg.principal_id IS NULL AND COALESCE(dap.member_default_role, wap.member_default_doc_role) = ANY(?::text[]))`,
|
||||
`OR (wap.sharing_enabled AND dap.visibility = 'public' AND dap.public_role = ANY(?::text[]))`,
|
||||
`))`,
|
||||
].join(' '),
|
||||
params: [
|
||||
input.userId,
|
||||
input.userId,
|
||||
input.workspaceId,
|
||||
grantRoles,
|
||||
nonMemberGrantRoles,
|
||||
inheritedWorkspaceRoles,
|
||||
grantRoles,
|
||||
docRoles,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
docReadableByNewTablesSql(input: {
|
||||
workspaceId: string;
|
||||
userId?: string;
|
||||
action: DocAction;
|
||||
docIdColumn?: Prisma.Sql;
|
||||
}): Prisma.Sql {
|
||||
const docRoles = this.docRolesForAction(input.action);
|
||||
const grantRoles = docRoles.filter(role => role !== 'external');
|
||||
const nonMemberGrantRoles = this.nonMemberDocGrantRolesForAction(
|
||||
input.action
|
||||
);
|
||||
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
|
||||
input.action
|
||||
);
|
||||
const docIdColumn = input.docIdColumn ?? Prisma.raw('doc_id');
|
||||
|
||||
return Prisma.sql`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM workspace_access_policies wap
|
||||
LEFT JOIN doc_access_policies dap
|
||||
ON dap.workspace_id = wap.workspace_id
|
||||
AND dap.doc_id = ${docIdColumn}
|
||||
LEFT JOIN workspace_members wm
|
||||
ON wm.workspace_id = wap.workspace_id
|
||||
AND wm.user_id = ${input.userId}
|
||||
AND wm.state = 'active'
|
||||
LEFT JOIN doc_grants dg
|
||||
ON dg.workspace_id = wap.workspace_id
|
||||
AND dg.doc_id = ${docIdColumn}
|
||||
AND dg.principal_type = 'user'
|
||||
AND dg.principal_id = ${input.userId}
|
||||
WHERE wap.workspace_id = ${input.workspaceId}
|
||||
AND (
|
||||
(wm.id IS NOT NULL AND dg.role = ANY(${Prisma.sql`${grantRoles}::text[]`}))
|
||||
OR (wm.id IS NULL AND wap.sharing_enabled AND dg.role = ANY(${Prisma.sql`${nonMemberGrantRoles}::text[]`}))
|
||||
OR wm.role = ANY(${Prisma.sql`${inheritedWorkspaceRoles}::text[]`})
|
||||
OR (wm.id IS NOT NULL AND dg.principal_id IS NULL AND COALESCE(dap.member_default_role, wap.member_default_doc_role) = ANY(${Prisma.sql`${grantRoles}::text[]`}))
|
||||
OR (wap.sharing_enabled AND dap.visibility = 'public' AND dap.public_role = ANY(${Prisma.sql`${docRoles}::text[]`}))
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,7 @@ export const RoleActionsMap = {
|
||||
Action.Doc.Delete,
|
||||
Action.Doc.Properties.Update,
|
||||
Action.Doc.Update,
|
||||
Action.Doc.Comments.Update,
|
||||
Action.Doc.Comments.Resolve,
|
||||
Action.Doc.Comments.Delete,
|
||||
];
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { SpaceAccessDenied } from '../../base';
|
||||
import { DocRole, Models } from '../../models';
|
||||
import { AccessController } from './controller';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
fixupDocRole,
|
||||
mapDocRoleToPermissions,
|
||||
mapWorkspaceRoleToPermissions,
|
||||
WorkspaceAction,
|
||||
workspaceActionRequiredRole,
|
||||
WorkspaceRole,
|
||||
} from './types';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
protected readonly type = 'ws';
|
||||
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async role(resource: Resource<'ws'>) {
|
||||
const role = await this.getRole(resource);
|
||||
|
||||
return {
|
||||
role,
|
||||
permissions: await this.policy.applyWorkspacePermissions(
|
||||
resource.workspaceId,
|
||||
mapWorkspaceRoleToPermissions(role)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async can(resource: Resource<'ws'>, action: WorkspaceAction) {
|
||||
const { permissions, role } = await this.role(resource);
|
||||
const allow = permissions[action] || false;
|
||||
|
||||
if (!allow) {
|
||||
this.logger.debug('Workspace access check failed', {
|
||||
action,
|
||||
resource,
|
||||
role,
|
||||
requiredRole: workspaceActionRequiredRole(action),
|
||||
});
|
||||
}
|
||||
|
||||
return allow;
|
||||
}
|
||||
|
||||
async assert(resource: Resource<'ws'>, action: WorkspaceAction) {
|
||||
const allow = await this.can(resource, action);
|
||||
|
||||
if (!allow) {
|
||||
throw new SpaceAccessDenied({ spaceId: resource.workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async getRole(payload: Resource<'ws'>) {
|
||||
const userRole = await this.models.workspaceUser.getActive(
|
||||
payload.workspaceId,
|
||||
payload.userId
|
||||
);
|
||||
|
||||
let role = userRole?.type as WorkspaceRole | null;
|
||||
|
||||
if (!role) {
|
||||
role = await this.defaultWorkspaceRole(payload);
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async docRoles(payload: Resource<'ws'>, docIds: string[]) {
|
||||
const docRoles = await this.getDocRoles(payload, docIds);
|
||||
return docRoles.map(role => ({
|
||||
role,
|
||||
permissions: mapDocRoleToPermissions(role),
|
||||
}));
|
||||
}
|
||||
|
||||
async getDocRoles(payload: Resource<'ws'>, docIds: string[]) {
|
||||
const docRoles: (DocRole | null)[] = [];
|
||||
|
||||
if (docIds.length === 0) {
|
||||
return docRoles;
|
||||
}
|
||||
|
||||
const workspaceRole = await this.getRole(payload);
|
||||
const sharingAllowed = await this.policy.isSharingEnabled(
|
||||
payload.workspaceId
|
||||
);
|
||||
if (
|
||||
!sharingAllowed &&
|
||||
(workspaceRole === null || workspaceRole === WorkspaceRole.External)
|
||||
) {
|
||||
return docIds.map(() => null);
|
||||
}
|
||||
|
||||
const userRoles = await this.models.docUser.findMany(
|
||||
payload.workspaceId,
|
||||
docIds,
|
||||
payload.userId
|
||||
);
|
||||
const userRolesMap = new Map(userRoles.map(role => [role.docId, role]));
|
||||
|
||||
const noUserRoleDocIds = docIds.filter(docId => {
|
||||
const userRole = userRolesMap.get(docId);
|
||||
return (userRole?.type ?? null) === null;
|
||||
});
|
||||
const defaultDocRoles =
|
||||
noUserRoleDocIds.length > 0
|
||||
? await this.getDocDefaultRoles(
|
||||
payload,
|
||||
noUserRoleDocIds,
|
||||
workspaceRole
|
||||
)
|
||||
: [];
|
||||
const defaultDocRolesMap = new Map(
|
||||
defaultDocRoles.map((role, index) => [noUserRoleDocIds[index], role])
|
||||
);
|
||||
|
||||
for (const docId of docIds) {
|
||||
const userRole = userRolesMap.get(docId);
|
||||
|
||||
let docRole: DocRole | null = userRole?.type ?? null;
|
||||
|
||||
// fallback logic
|
||||
if (docRole === null) {
|
||||
docRole = defaultDocRolesMap.get(docId) ?? null;
|
||||
}
|
||||
|
||||
// we need to fixup doc role to make sure it's not miss set
|
||||
// for example: workspace owner will have doc owner role
|
||||
// workspace external will not have role higher than editor
|
||||
const role = fixupDocRole(workspaceRole, docRole);
|
||||
|
||||
// never return [None]
|
||||
docRoles.push(role === DocRole.None ? null : role);
|
||||
}
|
||||
|
||||
return docRoles;
|
||||
}
|
||||
|
||||
private async getDocDefaultRoles(
|
||||
payload: Resource<'ws'>,
|
||||
docIds: string[],
|
||||
workspaceRole: WorkspaceRole | null
|
||||
) {
|
||||
const fallbackDocRoles: (DocRole | null)[] = [];
|
||||
|
||||
if (docIds.length === 0) {
|
||||
return fallbackDocRoles;
|
||||
}
|
||||
|
||||
const defaultDocRoles = await this.models.doc.findDefaultRoles(
|
||||
payload.workspaceId,
|
||||
docIds
|
||||
);
|
||||
|
||||
for (const defaultDocRole of defaultDocRoles) {
|
||||
let docRole: DocRole | null;
|
||||
// if user is in workspace but doc role is not set, fallback to default doc role
|
||||
if (workspaceRole !== null && workspaceRole !== WorkspaceRole.External) {
|
||||
docRole =
|
||||
defaultDocRole.external !== null
|
||||
? // edgecase: when doc role set to [None] for workspace member, but doc is public, we should fallback to external role
|
||||
Math.max(defaultDocRole.workspace, defaultDocRole.external)
|
||||
: defaultDocRole.workspace;
|
||||
} else {
|
||||
// else fallback to external doc role
|
||||
docRole = defaultDocRole.external;
|
||||
}
|
||||
|
||||
fallbackDocRoles.push(docRole);
|
||||
}
|
||||
|
||||
return fallbackDocRoles;
|
||||
}
|
||||
|
||||
private async defaultWorkspaceRole(payload: Resource<'ws'>) {
|
||||
const ws = await this.models.workspace.get(payload.workspaceId);
|
||||
|
||||
// NOTE(@forehalo):
|
||||
// we allow user to use online service with local workspace
|
||||
// so we always return owner role for local workspace
|
||||
// copilot session for local workspace is an example
|
||||
if (!ws) {
|
||||
if (payload.allowLocal) {
|
||||
return WorkspaceRole.Owner;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ws.public) {
|
||||
const sharingAllowed = await this.policy.canReadWorkspaceByPublicFlag(
|
||||
ws.id
|
||||
);
|
||||
return sharingAllowed ? WorkspaceRole.External : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { ExecutionContext, TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { EventBus } from '../../../base';
|
||||
import {
|
||||
Models,
|
||||
Workspace,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { EntitlementModule, EntitlementService } from '../../entitlement';
|
||||
import { QuotaService } from '../service';
|
||||
import { QuotaServiceModule } from '../service.module';
|
||||
import { QuotaStateService } from '../state';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
entitlement: EntitlementService;
|
||||
quota: QuotaService;
|
||||
state: QuotaStateService;
|
||||
}
|
||||
|
||||
const test = ava.serial as TestFn<Context>;
|
||||
const ONE_GB = 1024 * 1024 * 1024;
|
||||
const ONE_DAY_SECONDS = 24 * 60 * 60;
|
||||
type CaseState = {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({
|
||||
imports: [EntitlementModule, QuotaServiceModule],
|
||||
});
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
t.context.quota = module.get(QuotaService);
|
||||
t.context.state = module.get(QuotaStateService);
|
||||
});
|
||||
|
||||
test('quota service ignores dirty legacy commercial features', async t => {
|
||||
const { owner, workspace } = await createWorkspace(t);
|
||||
await t.context.models.userFeature.add(
|
||||
owner.id,
|
||||
'pro_plan_v1',
|
||||
'dirty legacy feature'
|
||||
);
|
||||
await t.context.models.userFeature.add(
|
||||
owner.id,
|
||||
'unlimited_copilot',
|
||||
'dirty legacy feature'
|
||||
);
|
||||
await t.context.models.workspaceFeature.add(
|
||||
workspace.id,
|
||||
'team_plan_v1',
|
||||
'dirty legacy feature',
|
||||
{
|
||||
memberLimit: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const userQuota = await t.context.quota.getUserQuota(owner.id);
|
||||
const workspaceSeats = await t.context.quota.getWorkspaceSeatQuota(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.is(userQuota.name, 'Free');
|
||||
t.is(userQuota.copilotActionLimit, 10);
|
||||
t.is(workspaceSeats.memberLimit, 3);
|
||||
});
|
||||
|
||||
test('workspace quota state ignores dirty legacy readonly feature', async t => {
|
||||
const { workspace } = await createWorkspace(t);
|
||||
await t.context.models.workspaceFeature.add(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1',
|
||||
'dirty legacy feature'
|
||||
);
|
||||
|
||||
const state = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.false(state.readonly);
|
||||
t.deepEqual(state.readonlyReasons, []);
|
||||
});
|
||||
|
||||
test('workspace quota state ignores dirty legacy permission rows', async t => {
|
||||
const { workspace } = await createWorkspace(t);
|
||||
const member = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
member.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await t.context.db.$transaction(async tx => {
|
||||
await tx.$executeRaw`
|
||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
||||
`;
|
||||
await tx.workspaceMember.deleteMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const state = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.is(state.memberCount, 1);
|
||||
});
|
||||
|
||||
test('quota service exposes history period in seconds', async t => {
|
||||
const { owner, workspace } = await createWorkspace(t);
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const userState = await t.context.state.reconcileUserQuotaState(owner.id);
|
||||
const workspaceState = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
const workspaceQuota = await t.context.quota.getWorkspaceQuota(workspace.id);
|
||||
|
||||
t.is(userState.historyPeriodSeconds, 30 * ONE_DAY_SECONDS);
|
||||
t.is(workspaceState.historyPeriodSeconds, 30 * ONE_DAY_SECONDS);
|
||||
t.is(workspaceQuota.historyPeriod, 30 * ONE_DAY_SECONDS);
|
||||
t.is(
|
||||
t.context.quota.formatWorkspaceQuota({
|
||||
...workspaceQuota,
|
||||
usedStorageQuota: 0,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
}).historyPeriod,
|
||||
'30 days'
|
||||
);
|
||||
});
|
||||
|
||||
test('quota state reconcile does not publish unchanged snapshots', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'quota-event-owner@affine.pro',
|
||||
});
|
||||
await t.context.db.effectiveUserQuotaState.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const event = t.context.module.get(EventBus);
|
||||
let changes = 0;
|
||||
event.on('user.quota_state.changed', ({ userId }) => {
|
||||
if (userId === user.id) {
|
||||
changes += 1;
|
||||
}
|
||||
});
|
||||
|
||||
await t.context.state.reconcileUserQuotaState(user.id);
|
||||
await t.context.state.reconcileUserQuotaState(user.id);
|
||||
|
||||
t.is(changes, 1);
|
||||
});
|
||||
|
||||
test('workspace quota state requires owner from new permission table', async t => {
|
||||
const { owner, workspace } = await createWorkspace(t);
|
||||
await t.context.db.$transaction(async tx => {
|
||||
await tx.$executeRaw`
|
||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
||||
`;
|
||||
await tx.workspaceMember.deleteMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: owner.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.state.reconcileWorkspaceQuotaState(workspace.id),
|
||||
{ message: 'Workspace owner not found' }
|
||||
);
|
||||
});
|
||||
|
||||
test('user quota state aggregates owned storage from new permission table only', async t => {
|
||||
const { owner, workspace } = await createWorkspace(t);
|
||||
await addBlob(t, workspace, 'blob', ONE_GB);
|
||||
|
||||
const first = await t.context.state.reconcileUserQuotaState(owner.id);
|
||||
await t.context.db.$transaction(async tx => {
|
||||
await tx.$executeRaw`
|
||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
||||
`;
|
||||
await tx.workspaceMember.deleteMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: owner.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
const second = await t.context.state.reconcileUserQuotaState(owner.id);
|
||||
|
||||
t.is(first.usedStorageQuota, BigInt(ONE_GB));
|
||||
t.is(second.usedStorageQuota, 0n);
|
||||
});
|
||||
|
||||
test('user quota state keeps ai capability alongside pro entitlement', async t => {
|
||||
const { owner } = await createWorkspace(t);
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const state = await t.context.state.reconcileUserQuotaState(owner.id);
|
||||
const quota = await t.context.quota.getUserQuota(owner.id);
|
||||
|
||||
t.is(state.plan, 'pro');
|
||||
t.deepEqual(state.flags, { unlimitedCopilot: true });
|
||||
t.is(quota.copilotActionLimit, undefined);
|
||||
});
|
||||
|
||||
test('ai entitlement is a capability overlay on free quota', async t => {
|
||||
const { owner } = await createWorkspace(t);
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const state = await t.context.state.reconcileUserQuotaState(owner.id);
|
||||
const quota = await t.context.quota.getUserQuota(owner.id);
|
||||
|
||||
t.is(state.plan, 'free');
|
||||
t.deepEqual(state.flags, { unlimitedCopilot: true });
|
||||
t.is(quota.name, 'Free');
|
||||
t.is(quota.copilotActionLimit, undefined);
|
||||
});
|
||||
|
||||
test('workspace team status ignores dirty legacy feature', async t => {
|
||||
const { workspace } = await createWorkspace(t);
|
||||
await t.context.models.workspaceFeature.add(
|
||||
workspace.id,
|
||||
'team_plan_v1',
|
||||
'dirty legacy feature',
|
||||
{
|
||||
memberLimit: 100,
|
||||
}
|
||||
);
|
||||
|
||||
t.false(await t.context.models.workspace.isTeamWorkspace(workspace.id));
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
quantity: 5,
|
||||
});
|
||||
|
||||
t.true(await t.context.models.workspace.isTeamWorkspace(workspace.id));
|
||||
});
|
||||
|
||||
test('selfhosted builtin free has cloud pro quota rights', async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific quota semantics
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
try {
|
||||
const { owner, workspace } = await createWorkspace(t);
|
||||
|
||||
const userState = await t.context.state.reconcileUserQuotaState(owner.id);
|
||||
const userQuota = await t.context.quota.getUserQuota(owner.id);
|
||||
const workspaceState = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
const workspaceQuota = await t.context.quota.getWorkspaceQuota(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.is(userState.plan, 'selfhost_free');
|
||||
t.is(userState.storageQuota, BigInt(100 * ONE_GB));
|
||||
t.is(userQuota.name, 'Pro');
|
||||
t.is(userQuota.memberLimit, 10);
|
||||
t.is(workspaceState.plan, 'selfhost_free');
|
||||
t.is(workspaceQuota.name, 'Pro');
|
||||
t.is(workspaceQuota.memberLimit, 10);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('reconciles quota states from entitlements and business tables', async t => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'owner fallback uses user entitlement and owner storage usage',
|
||||
setup: async () => {
|
||||
const { owner, workspace } = await createWorkspace(t);
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
await addBlob(t, workspace, 'blob', ONE_GB);
|
||||
|
||||
return { userId: owner.id, workspaceId: workspace.id };
|
||||
},
|
||||
assert: async ({ userId, workspaceId }: CaseState) => {
|
||||
const user = await t.context.state.reconcileUserQuotaState(userId!);
|
||||
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspaceId!
|
||||
);
|
||||
|
||||
t.is(user.plan, 'pro');
|
||||
t.is(user.usedStorageQuota, BigInt(ONE_GB));
|
||||
t.true(workspace.usesOwnerQuota);
|
||||
t.is(workspace.plan, 'pro');
|
||||
t.is(
|
||||
(await t.context.quota.getWorkspaceQuota(workspaceId!)).name,
|
||||
'Pro'
|
||||
);
|
||||
t.is(workspace.storageQuota, BigInt(100 * ONE_GB));
|
||||
t.is(workspace.usedStorageQuota, BigInt(ONE_GB));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'team entitlement owns workspace quota',
|
||||
setup: async () => {
|
||||
const { workspace } = await createWorkspace(t);
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
quantity: 5,
|
||||
});
|
||||
|
||||
return { workspaceId: workspace.id };
|
||||
},
|
||||
assert: async ({ workspaceId }: CaseState) => {
|
||||
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspaceId!
|
||||
);
|
||||
|
||||
t.false(workspace.usesOwnerQuota);
|
||||
t.is(workspace.seatLimit, 5);
|
||||
t.is(workspace.storageQuota, BigInt(200 * ONE_GB));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'overcapacity members set readonly state',
|
||||
setup: async () => {
|
||||
const { workspace } = await createWorkspace(t);
|
||||
await addAcceptedMembers(t, workspace.id, 4);
|
||||
|
||||
return { workspaceId: workspace.id };
|
||||
},
|
||||
assert: async ({ workspaceId }: CaseState) => {
|
||||
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspaceId!
|
||||
);
|
||||
|
||||
t.true(workspace.readonly);
|
||||
t.deepEqual(workspace.readonlyReasons, ['member_overflow']);
|
||||
t.is(workspace.overcapacityMemberCount, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'storage overflow sets readonly state',
|
||||
setup: async () => {
|
||||
const { workspace } = await createWorkspace(t);
|
||||
for (let index = 0; index < 11; index++) {
|
||||
await addBlob(t, workspace, `blob-${index}`, ONE_GB);
|
||||
}
|
||||
|
||||
return { workspaceId: workspace.id };
|
||||
},
|
||||
assert: async ({ workspaceId }: CaseState) => {
|
||||
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
|
||||
workspaceId!
|
||||
);
|
||||
|
||||
t.true(workspace.readonly);
|
||||
t.deepEqual(workspace.readonlyReasons, ['storage_overflow']);
|
||||
t.is(workspace.usedStorageQuota, BigInt(11 * ONE_GB));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'expired entitlement falls back to free state',
|
||||
setup: async () => {
|
||||
const { owner } = await createWorkspace(t);
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'canceled',
|
||||
});
|
||||
|
||||
return { userId: owner.id };
|
||||
},
|
||||
assert: async ({ userId }: CaseState) => {
|
||||
const user = await t.context.state.reconcileUserQuotaState(userId!);
|
||||
|
||||
t.is(user.plan, 'free');
|
||||
t.is(user.sourceEntitlementId, null);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
await t.context.module.initTestingDB();
|
||||
const state = await item.setup();
|
||||
await item.assert(state);
|
||||
}
|
||||
});
|
||||
|
||||
async function createWorkspace(t: ExecutionContext<Context>) {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
return { owner, workspace };
|
||||
}
|
||||
|
||||
async function addAcceptedMembers(
|
||||
t: ExecutionContext<Context>,
|
||||
workspaceId: string,
|
||||
count: number
|
||||
) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const member = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspaceId,
|
||||
member.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function addBlob(
|
||||
t: ExecutionContext<Context>,
|
||||
workspace: Workspace,
|
||||
key: string,
|
||||
size: number
|
||||
) {
|
||||
await t.context.models.blob.upsert({
|
||||
workspaceId: workspace.id,
|
||||
key,
|
||||
mime: 'application/octet-stream',
|
||||
size,
|
||||
});
|
||||
}
|
||||
@@ -19,4 +19,5 @@ export class QuotaModule {}
|
||||
|
||||
export { QuotaService };
|
||||
export { QuotaServiceModule };
|
||||
export { QuotaStateService } from './state';
|
||||
export { WorkspaceQuotaHumanReadableType, WorkspaceQuotaType } from './types';
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OnEvent, SpaceAccessDenied } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeUserQuotaStateRoom,
|
||||
realtimeWorkspaceQuotaStateRoom,
|
||||
} from '../realtime/rooms';
|
||||
import { QuotaStateService } from './state';
|
||||
|
||||
type UserQuotaStateSnapshot = import('@affine/realtime').UserQuotaStateSnapshot;
|
||||
type WorkspaceQuotaStateSnapshot =
|
||||
import('@affine/realtime').WorkspaceQuotaStateSnapshot;
|
||||
|
||||
declare module '@affine/realtime' {
|
||||
interface RealtimeRequestMap {
|
||||
'user.quota-state.get': {
|
||||
input: Record<string, never>;
|
||||
output: { state: UserQuotaStateSnapshot };
|
||||
};
|
||||
'workspace.quota-state.get': {
|
||||
input: { workspaceId: string };
|
||||
output: { state: WorkspaceQuotaStateSnapshot };
|
||||
};
|
||||
}
|
||||
|
||||
interface RealtimeTopicMap {
|
||||
'user.quota-state.changed': {
|
||||
input: Record<string, never>;
|
||||
event: { changed: true };
|
||||
};
|
||||
'workspace.quota-state.changed': {
|
||||
input: { workspaceId: string };
|
||||
event: { changed: true };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuotaStateRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly quotaState: QuotaStateService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
const { registry } = this;
|
||||
if (!registry) return;
|
||||
|
||||
const workspaceInput = z.object({ workspaceId: z.string() });
|
||||
|
||||
registerRealtimeLiveQuery(registry, {
|
||||
request: {
|
||||
name: 'user.quota-state.get',
|
||||
input: z.object({}),
|
||||
handle: async user => ({
|
||||
state: this.serializeState(
|
||||
await this.quotaState.reconcileUserQuotaState(user.id)
|
||||
) as unknown as UserQuotaStateSnapshot,
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.quota-state.changed',
|
||||
input: z.object({}),
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserQuotaStateRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(registry, {
|
||||
request: {
|
||||
name: 'workspace.quota-state.get',
|
||||
input: workspaceInput,
|
||||
handle: async (user, payload) => {
|
||||
await this.assertWorkspace(user.id, payload.workspaceId);
|
||||
return {
|
||||
state: this.serializeState(
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(
|
||||
payload.workspaceId
|
||||
)
|
||||
) as unknown as WorkspaceQuotaStateSnapshot,
|
||||
};
|
||||
},
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.quota-state.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, payload) => {
|
||||
await this.assertWorkspace(user.id, payload.workspaceId);
|
||||
},
|
||||
room: (_user, payload) =>
|
||||
realtimeWorkspaceQuotaStateRoom(payload.workspaceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.quota_state.changed', { suppressError: true })
|
||||
async onUserQuotaStateChanged({
|
||||
userId,
|
||||
}: Events['user.quota_state.changed']) {
|
||||
this.publisher?.publish(
|
||||
'user.quota-state.changed',
|
||||
{},
|
||||
{ changed: true },
|
||||
{ room: realtimeUserQuotaStateRoom(userId) }
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.quota_state.changed', { suppressError: true })
|
||||
async onWorkspaceQuotaStateChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.quota_state.changed']) {
|
||||
this.publisher?.publish(
|
||||
'workspace.quota-state.changed',
|
||||
{ workspaceId },
|
||||
{ changed: true },
|
||||
{ room: realtimeWorkspaceQuotaStateRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
|
||||
private async assertWorkspace(userId: string, workspaceId: string) {
|
||||
const role = await this.models.workspaceUser.getActive(workspaceId, userId);
|
||||
if (!role) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
private serializeState<T extends Record<string, unknown>>(state: T) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(state).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'bigint' ? Number(value) : value,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { EntitlementModule } from '../entitlement';
|
||||
import { StorageModule } from '../storage';
|
||||
import { QuotaStateRealtimeProvider } from './realtime';
|
||||
import { QuotaService } from './service';
|
||||
import { QuotaStateService } from './state';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [QuotaService],
|
||||
exports: [QuotaService],
|
||||
imports: [StorageModule, EntitlementModule],
|
||||
providers: [QuotaService, QuotaStateService, QuotaStateRealtimeProvider],
|
||||
exports: [QuotaService, QuotaStateService],
|
||||
})
|
||||
export class QuotaServiceModule {}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { InternalServerError, MemberQuotaExceeded, OnEvent } from '../../base';
|
||||
import { MemberQuotaExceeded, OnEvent } from '../../base';
|
||||
import {
|
||||
Models,
|
||||
type UserQuota,
|
||||
WorkspaceQuota as BaseWorkspaceQuota,
|
||||
WorkspaceRole,
|
||||
} from '../../models';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { QuotaStateService } from './state';
|
||||
import {
|
||||
UserQuotaHumanReadableType,
|
||||
UserQuotaType,
|
||||
@@ -29,10 +27,7 @@ export type WorkspaceQuotaWithUsage = Omit<
|
||||
export class QuotaService {
|
||||
protected logger = new Logger(QuotaService.name);
|
||||
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly storage: WorkspaceBlobStorage
|
||||
) {}
|
||||
constructor(private readonly quotaState: QuotaStateService) {}
|
||||
|
||||
@OnEvent('user.postCreated')
|
||||
async onUserCreated({ id }: Events['user.postCreated']) {
|
||||
@@ -40,121 +35,48 @@ export class QuotaService {
|
||||
}
|
||||
|
||||
async getUserQuota(userId: string): Promise<UserQuota> {
|
||||
let quota = await this.models.userFeature.getQuota(userId);
|
||||
const state = await this.quotaState.reconcileUserQuotaState(userId);
|
||||
|
||||
// not possible, but just in case, we do a little fix for user to avoid system dump
|
||||
if (!quota) {
|
||||
await this.setupUserBaseQuota(userId);
|
||||
quota = await this.models.userFeature.getQuota(userId);
|
||||
}
|
||||
|
||||
const unlimitedCopilot = await this.models.userFeature.has(
|
||||
userId,
|
||||
'unlimited_copilot'
|
||||
);
|
||||
|
||||
if (!quota) {
|
||||
throw new InternalServerError(
|
||||
'User quota not found and can not be created.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...quota.configs,
|
||||
copilotActionLimit: unlimitedCopilot
|
||||
? undefined
|
||||
: quota.configs.copilotActionLimit,
|
||||
} as UserQuotaWithUsage;
|
||||
return this.userQuotaFromState(state);
|
||||
}
|
||||
|
||||
async getUserQuotaWithUsage(userId: string): Promise<UserQuotaWithUsage> {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
const usedStorageQuota = await this.getUserStorageUsage(userId);
|
||||
const state = await this.quotaState.reconcileUserQuotaState(userId);
|
||||
const quota = this.userQuotaFromState(state);
|
||||
|
||||
return { ...quota, usedStorageQuota };
|
||||
return { ...quota, usedStorageQuota: Number(state.usedStorageQuota) };
|
||||
}
|
||||
|
||||
async getUserStorageUsage(userId: string) {
|
||||
const workspaces = await this.models.workspaceUser.getUserActiveRoles(
|
||||
userId,
|
||||
{
|
||||
role: WorkspaceRole.Owner,
|
||||
}
|
||||
);
|
||||
|
||||
const ids = workspaces.map(w => w.workspaceId);
|
||||
|
||||
const workspacesWithQuota =
|
||||
await this.models.workspaceFeature.batchHasQuota(ids);
|
||||
|
||||
const sizes = await Promise.allSettled(
|
||||
ids
|
||||
.filter(w => !workspacesWithQuota.includes(w))
|
||||
.map(workspace => this.storage.totalSize(workspace))
|
||||
);
|
||||
|
||||
return sizes.reduce((total, size) => {
|
||||
if (size.status === 'fulfilled') {
|
||||
// ensure that size is within the safe range of gql
|
||||
const totalSize = total + size.value;
|
||||
if (Number.isSafeInteger(totalSize)) {
|
||||
return totalSize;
|
||||
} else {
|
||||
this.logger.error(`Workspace size is invalid: ${size.value}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Failed to get workspace size`, size.reason);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
const state = await this.quotaState.reconcileUserQuotaState(userId);
|
||||
return Number(state.usedStorageQuota);
|
||||
}
|
||||
|
||||
async getWorkspaceStorageUsage(workspaceId: string) {
|
||||
const totalSize = await this.storage.totalSize(workspaceId);
|
||||
// ensure that size is within the safe range of gql
|
||||
if (Number.isSafeInteger(totalSize)) {
|
||||
return totalSize;
|
||||
} else {
|
||||
this.logger.error(`Workspace size is invalid: ${totalSize}`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
const state =
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
return Number(state.usedStorageQuota);
|
||||
}
|
||||
|
||||
async getWorkspaceQuota(workspaceId: string): Promise<WorkspaceQuota> {
|
||||
const quota = await this.models.workspaceFeature.getQuota(workspaceId);
|
||||
|
||||
if (!quota) {
|
||||
// get and convert to workspace quota from owner's quota
|
||||
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||
const ownerQuota = await this.getUserQuota(owner.id);
|
||||
|
||||
return {
|
||||
...ownerQuota,
|
||||
ownerQuota: owner.id,
|
||||
};
|
||||
}
|
||||
|
||||
return quota.configs;
|
||||
const state =
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
return this.workspaceQuotaFromState(state);
|
||||
}
|
||||
|
||||
async getWorkspaceQuotaWithUsage(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceQuotaWithUsage> {
|
||||
const quota = await this.getWorkspaceQuota(workspaceId);
|
||||
const usedStorageQuota = quota.ownerQuota
|
||||
? await this.getUserStorageUsage(quota.ownerQuota)
|
||||
: await this.getWorkspaceStorageUsage(workspaceId);
|
||||
const memberCount =
|
||||
await this.models.workspaceUser.chargedCount(workspaceId);
|
||||
const overcapacityMemberCount = memberCount - quota.memberLimit;
|
||||
const state =
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
const quota = this.workspaceQuotaFromState(state);
|
||||
|
||||
return {
|
||||
...quota,
|
||||
usedStorageQuota,
|
||||
memberCount,
|
||||
overcapacityMemberCount,
|
||||
usedSize: usedStorageQuota,
|
||||
usedStorageQuota: Number(state.usedStorageQuota),
|
||||
memberCount: state.memberCount,
|
||||
overcapacityMemberCount: state.overcapacityMemberCount,
|
||||
usedSize: Number(state.usedStorageQuota),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,13 +97,12 @@ export class QuotaService {
|
||||
}
|
||||
|
||||
async getWorkspaceSeatQuota(workspaceId: string) {
|
||||
const quota = await this.getWorkspaceQuota(workspaceId);
|
||||
const memberCount =
|
||||
await this.models.workspaceUser.chargedCount(workspaceId);
|
||||
const state =
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
|
||||
return {
|
||||
memberCount,
|
||||
memberLimit: quota.memberLimit,
|
||||
memberCount: state.memberCount,
|
||||
memberLimit: state.seatLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,42 +136,27 @@ export class QuotaService {
|
||||
}
|
||||
|
||||
async getUserQuotaCalculator(userId: string) {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
const usedSize = await this.getUserStorageUsage(userId);
|
||||
const quota = await this.getUserQuotaWithUsage(userId);
|
||||
|
||||
return this.generateQuotaCalculator(
|
||||
quota.storageQuota,
|
||||
quota.blobLimit,
|
||||
usedSize
|
||||
quota.usedStorageQuota
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkspaceQuotaCalculator(workspaceId: string) {
|
||||
const quota = await this.getWorkspaceQuota(workspaceId);
|
||||
const unlimited = await this.models.workspaceFeature.has(
|
||||
workspaceId,
|
||||
'unlimited_workspace'
|
||||
);
|
||||
|
||||
// quota check will be disabled for unlimited workspace
|
||||
// we save a complicated db read for used size
|
||||
if (unlimited) {
|
||||
return this.generateQuotaCalculator(0, quota.blobLimit, 0, true);
|
||||
}
|
||||
|
||||
const usedSize = quota.ownerQuota
|
||||
? await this.getUserStorageUsage(quota.ownerQuota)
|
||||
: await this.getWorkspaceStorageUsage(workspaceId);
|
||||
const quota = await this.getWorkspaceQuotaWithUsage(workspaceId);
|
||||
|
||||
return this.generateQuotaCalculator(
|
||||
quota.storageQuota,
|
||||
quota.blobLimit,
|
||||
usedSize
|
||||
quota.usedStorageQuota
|
||||
);
|
||||
}
|
||||
|
||||
private async setupUserBaseQuota(userId: string) {
|
||||
await this.models.userFeature.add(userId, 'free_plan_v1', 'sign up');
|
||||
await this.quotaState.reconcileUserQuotaState(userId);
|
||||
}
|
||||
|
||||
private generateQuotaCalculator(
|
||||
@@ -278,4 +184,60 @@ export class QuotaService {
|
||||
};
|
||||
return checkExceeded;
|
||||
}
|
||||
|
||||
private userQuotaFromState(
|
||||
state: Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>
|
||||
): UserQuota {
|
||||
const flags = state.flags as { unlimitedCopilot?: boolean };
|
||||
return {
|
||||
name: this.planName(state.plan),
|
||||
blobLimit: Number(state.blobLimit),
|
||||
storageQuota: Number(state.storageQuota),
|
||||
historyPeriod: state.historyPeriodSeconds,
|
||||
memberLimit: this.userMemberLimit(state.plan),
|
||||
copilotActionLimit: flags.unlimitedCopilot
|
||||
? undefined
|
||||
: (state.copilotActionLimit ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
private workspaceQuotaFromState(
|
||||
state: Awaited<
|
||||
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
|
||||
>
|
||||
): WorkspaceQuota {
|
||||
return {
|
||||
name: this.planName(state.plan),
|
||||
blobLimit: Number(state.blobLimit),
|
||||
storageQuota: Number(state.storageQuota),
|
||||
historyPeriod: state.historyPeriodSeconds,
|
||||
memberLimit: state.seatLimit,
|
||||
ownerQuota: state.usesOwnerQuota
|
||||
? (state.ownerUserId ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private userMemberLimit(plan: string) {
|
||||
return plan === 'pro' || plan === 'lifetime_pro' || plan === 'selfhost_free'
|
||||
? 10
|
||||
: 3;
|
||||
}
|
||||
|
||||
private planName(plan: string) {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
case 'selfhost_free':
|
||||
return 'Pro';
|
||||
case 'lifetime_pro':
|
||||
return 'Lifetime Pro';
|
||||
case 'ai':
|
||||
return 'AI';
|
||||
case 'team':
|
||||
case 'selfhost_team':
|
||||
return 'Team';
|
||||
default:
|
||||
return 'Free';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { EventBus, OnEvent } from '../../base';
|
||||
import { EntitlementService } from '../entitlement';
|
||||
|
||||
type Quota = Awaited<
|
||||
ReturnType<EntitlementService['resolveUserEntitlement']>
|
||||
>['quota'];
|
||||
|
||||
const STATE_TTL = 1000 * 60 * 10;
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.quota_state.changed': {
|
||||
userId: string;
|
||||
};
|
||||
'workspace.quota_state.changed': {
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuotaStateService {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
async reconcileUserQuotaState(userId: string) {
|
||||
const [previous, entitlement, entitlements, resolved, usedStorageQuota] =
|
||||
await Promise.all([
|
||||
this.db.effectiveUserQuotaState.findUnique({ where: { userId } }),
|
||||
this.entitlement.getBestEntitlement('user', userId),
|
||||
this.entitlement.getActiveEntitlements('user', userId),
|
||||
this.entitlement.resolveUserEntitlement(userId),
|
||||
this.getOwnerStorageUsage(userId),
|
||||
]);
|
||||
const flags = {
|
||||
...resolved.flags,
|
||||
unlimitedCopilot: entitlements.some(
|
||||
entitlement => entitlement.plan === 'ai'
|
||||
),
|
||||
};
|
||||
const now = new Date();
|
||||
|
||||
const state = await this.db.effectiveUserQuotaState.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
plan: resolved.plan,
|
||||
sourceEntitlementId: entitlement?.id ?? null,
|
||||
...this.quotaData(resolved.quota),
|
||||
usedStorageQuota,
|
||||
flags,
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: now,
|
||||
staleAfter: this.staleAfter(now),
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
plan: resolved.plan,
|
||||
sourceEntitlementId: entitlement?.id ?? null,
|
||||
...this.quotaData(resolved.quota),
|
||||
usedStorageQuota,
|
||||
flags,
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: now,
|
||||
staleAfter: this.staleAfter(now),
|
||||
},
|
||||
});
|
||||
if (this.userQuotaStateChanged(previous, state)) {
|
||||
await this.event.emitAsync('user.quota_state.changed', { userId });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async reconcileWorkspaceQuotaState(workspaceId: string) {
|
||||
const owner = await this.getWorkspaceOwner(workspaceId);
|
||||
const [
|
||||
previous,
|
||||
entitlement,
|
||||
resolved,
|
||||
memberCount,
|
||||
workspaceStorageUsage,
|
||||
] = await Promise.all([
|
||||
this.db.effectiveWorkspaceQuotaState.findUnique({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.entitlement.getBestEntitlement('workspace', workspaceId),
|
||||
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
||||
this.getChargedMemberCount(workspaceId),
|
||||
this.getWorkspaceStorageUsage(workspaceId),
|
||||
]);
|
||||
const usesOwnerQuota = !this.hasStandaloneWorkspaceQuota(resolved.plan);
|
||||
const [ownerState, ownerEntitlement] = usesOwnerQuota
|
||||
? await Promise.all([
|
||||
this.reconcileUserQuotaState(owner.id),
|
||||
this.entitlement.resolveUserEntitlement(owner.id),
|
||||
])
|
||||
: [null, null];
|
||||
const quota = ownerEntitlement?.quota ?? resolved.quota;
|
||||
const plan = ownerEntitlement?.plan ?? resolved.plan;
|
||||
const usedStorageQuota = ownerState
|
||||
? ownerState.usedStorageQuota
|
||||
: workspaceStorageUsage;
|
||||
const storageQuota = BigInt(quota.storageQuota);
|
||||
const seatLimit = quota.seatLimit ?? 0;
|
||||
const overcapacityMemberCount = Math.max(memberCount - seatLimit, 0);
|
||||
const readonlyReasons = [
|
||||
overcapacityMemberCount > 0 ? 'member_overflow' : null,
|
||||
usedStorageQuota > storageQuota ? 'storage_overflow' : null,
|
||||
].filter((reason): reason is string => !!reason);
|
||||
const now = new Date();
|
||||
|
||||
const state = await this.db.effectiveWorkspaceQuotaState.upsert({
|
||||
where: { workspaceId },
|
||||
update: {
|
||||
plan,
|
||||
sourceEntitlementId: entitlement?.id ?? null,
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota,
|
||||
seatLimit,
|
||||
memberCount,
|
||||
overcapacityMemberCount,
|
||||
...this.workspaceQuotaData(quota),
|
||||
usedStorageQuota,
|
||||
readonly: readonlyReasons.length > 0,
|
||||
readonlyReasons,
|
||||
flags: resolved.flags,
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: now,
|
||||
staleAfter: this.staleAfter(now),
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
plan,
|
||||
sourceEntitlementId: entitlement?.id ?? null,
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota,
|
||||
seatLimit,
|
||||
memberCount,
|
||||
overcapacityMemberCount,
|
||||
...this.workspaceQuotaData(quota),
|
||||
usedStorageQuota,
|
||||
readonly: readonlyReasons.length > 0,
|
||||
readonlyReasons,
|
||||
flags: resolved.flags,
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: now,
|
||||
staleAfter: this.staleAfter(now),
|
||||
},
|
||||
});
|
||||
if (this.workspaceQuotaStateChanged(previous, state)) {
|
||||
await this.event.emitAsync('workspace.quota_state.changed', {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async reconcileAllEntitlementProjection() {
|
||||
const [users, workspaces] = await Promise.all([
|
||||
this.db.user.findMany({ select: { id: true } }),
|
||||
this.db.workspace.findMany({ select: { id: true } }),
|
||||
]);
|
||||
|
||||
await this.reconcileMany([
|
||||
...users.map(user => () => this.reconcileUserQuotaState(user.id)),
|
||||
...workspaces.map(
|
||||
workspace => () => this.reconcileWorkspaceQuotaState(workspace.id)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@OnEvent('entitlement.changed')
|
||||
async onEntitlementChanged({
|
||||
targetType,
|
||||
targetId,
|
||||
}: Events['entitlement.changed']) {
|
||||
if (targetType === 'user') {
|
||||
await this.reconcileUserQuotaState(targetId);
|
||||
await this.reconcileOwnedWorkspaces(targetId);
|
||||
} else if (targetType === 'workspace') {
|
||||
await this.reconcileWorkspaceQuotaState(targetId);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated')
|
||||
async onWorkspaceMembersUpdated({
|
||||
workspaceId,
|
||||
}: Events['workspace.members.updated']) {
|
||||
await this.reconcileWorkspaceQuotaState(workspaceId);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.owner.changed')
|
||||
async onWorkspaceOwnerChanged({
|
||||
workspaceId,
|
||||
from,
|
||||
to,
|
||||
}: Events['workspace.owner.changed']) {
|
||||
await this.reconcileWorkspaceQuotaState(workspaceId);
|
||||
await Promise.all([
|
||||
this.reconcileUserQuotaState(from),
|
||||
this.reconcileUserQuotaState(to),
|
||||
]);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blobs.updated')
|
||||
async onWorkspaceBlobsUpdated({
|
||||
workspaceId,
|
||||
}: Events['workspace.blobs.updated']) {
|
||||
const owner = await this.getWorkspaceOwner(workspaceId);
|
||||
await Promise.all([
|
||||
this.reconcileWorkspaceQuotaState(workspaceId),
|
||||
this.reconcileUserQuotaState(owner.id),
|
||||
]);
|
||||
}
|
||||
|
||||
private async reconcileOwnedWorkspaces(userId: string) {
|
||||
const workspaces = await this.getOwnedWorkspaceIds(userId);
|
||||
|
||||
await this.reconcileMany(
|
||||
workspaces.map(
|
||||
workspaceId => () => this.reconcileWorkspaceQuotaState(workspaceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getOwnerStorageUsage(userId: string) {
|
||||
const workspaces = await this.getOwnedWorkspaceIds(userId);
|
||||
const usages = await this.mapMany(workspaces, async workspaceId => {
|
||||
const entitlement =
|
||||
await this.entitlement.resolveWorkspaceEntitlement(workspaceId);
|
||||
|
||||
return this.hasStandaloneWorkspaceQuota(entitlement.plan)
|
||||
? 0n
|
||||
: this.getWorkspaceStorageUsage(workspaceId);
|
||||
});
|
||||
|
||||
return usages.reduce((total, usage) => total + usage, 0n);
|
||||
}
|
||||
|
||||
private async getWorkspaceOwner(workspaceId: string) {
|
||||
const owner = await this.db.workspaceMember.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
role: 'owner',
|
||||
state: 'active',
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!owner) {
|
||||
throw new Error('Workspace owner not found');
|
||||
}
|
||||
return owner.user;
|
||||
}
|
||||
|
||||
private async getChargedMemberCount(workspaceId: string) {
|
||||
const [members, invitations] = await Promise.all([
|
||||
this.db.workspaceMember.count({
|
||||
where: { workspaceId, state: 'active' },
|
||||
}),
|
||||
this.db.workspaceInvitation.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: {
|
||||
not: 'waiting_review',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return members + invitations;
|
||||
}
|
||||
|
||||
private async getOwnedWorkspaceIds(userId: string) {
|
||||
const workspaces = await this.db.workspaceMember.findMany({
|
||||
where: {
|
||||
userId,
|
||||
role: 'owner',
|
||||
state: 'active',
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
},
|
||||
});
|
||||
return workspaces.map(workspace => workspace.workspaceId);
|
||||
}
|
||||
|
||||
private async getWorkspaceStorageUsage(workspaceId: string) {
|
||||
const sum = await this.db.blob.aggregate({
|
||||
where: {
|
||||
workspaceId,
|
||||
deletedAt: null,
|
||||
},
|
||||
_sum: {
|
||||
size: true,
|
||||
},
|
||||
});
|
||||
|
||||
return BigInt(sum._sum.size ?? 0);
|
||||
}
|
||||
|
||||
private hasStandaloneWorkspaceQuota(plan: string) {
|
||||
return plan === 'team' || plan === 'selfhost_team';
|
||||
}
|
||||
|
||||
private quotaData(quota: Quota) {
|
||||
return {
|
||||
blobLimit: BigInt(quota.blobLimit),
|
||||
storageQuota: BigInt(quota.storageQuota),
|
||||
historyPeriodSeconds: quota.historyPeriod,
|
||||
copilotActionLimit: quota.copilotActionLimit ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private workspaceQuotaData(quota: Quota) {
|
||||
return {
|
||||
blobLimit: BigInt(quota.blobLimit),
|
||||
storageQuota: BigInt(quota.storageQuota),
|
||||
historyPeriodSeconds: quota.historyPeriod,
|
||||
};
|
||||
}
|
||||
|
||||
private async reconcileMany(tasks: Array<() => Promise<unknown>>) {
|
||||
await this.mapMany(tasks, task => task());
|
||||
}
|
||||
|
||||
private async mapMany<T, U>(items: T[], mapper: (item: T) => Promise<U>) {
|
||||
const batchSize = 16;
|
||||
const results: U[] = [];
|
||||
for (let index = 0; index < items.length; index += batchSize) {
|
||||
results.push(
|
||||
...(await Promise.all(
|
||||
items.slice(index, index + batchSize).map(item => mapper(item))
|
||||
))
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private userQuotaStateChanged(
|
||||
previous: Awaited<
|
||||
ReturnType<PrismaClient['effectiveUserQuotaState']['findUnique']>
|
||||
>,
|
||||
current: Awaited<
|
||||
ReturnType<PrismaClient['effectiveUserQuotaState']['upsert']>
|
||||
>
|
||||
) {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
previous.plan !== current.plan ||
|
||||
previous.sourceEntitlementId !== current.sourceEntitlementId ||
|
||||
previous.blobLimit !== current.blobLimit ||
|
||||
previous.storageQuota !== current.storageQuota ||
|
||||
previous.usedStorageQuota !== current.usedStorageQuota ||
|
||||
previous.historyPeriodSeconds !== current.historyPeriodSeconds ||
|
||||
previous.copilotActionLimit !== current.copilotActionLimit ||
|
||||
previous.known !== current.known ||
|
||||
previous.stale !== current.stale ||
|
||||
JSON.stringify(previous.flags) !== JSON.stringify(current.flags)
|
||||
);
|
||||
}
|
||||
|
||||
private workspaceQuotaStateChanged(
|
||||
previous: Awaited<
|
||||
ReturnType<PrismaClient['effectiveWorkspaceQuotaState']['findUnique']>
|
||||
>,
|
||||
current: Awaited<
|
||||
ReturnType<PrismaClient['effectiveWorkspaceQuotaState']['upsert']>
|
||||
>
|
||||
) {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
previous.plan !== current.plan ||
|
||||
previous.sourceEntitlementId !== current.sourceEntitlementId ||
|
||||
previous.ownerUserId !== current.ownerUserId ||
|
||||
previous.usesOwnerQuota !== current.usesOwnerQuota ||
|
||||
previous.seatLimit !== current.seatLimit ||
|
||||
previous.memberCount !== current.memberCount ||
|
||||
previous.overcapacityMemberCount !== current.overcapacityMemberCount ||
|
||||
previous.blobLimit !== current.blobLimit ||
|
||||
previous.storageQuota !== current.storageQuota ||
|
||||
previous.usedStorageQuota !== current.usedStorageQuota ||
|
||||
previous.historyPeriodSeconds !== current.historyPeriodSeconds ||
|
||||
previous.readonly !== current.readonly ||
|
||||
previous.known !== current.known ||
|
||||
previous.stale !== current.stale ||
|
||||
previous.readonlyReasons.join(',') !==
|
||||
current.readonlyReasons.join(',') ||
|
||||
JSON.stringify(previous.flags) !== JSON.stringify(current.flags)
|
||||
);
|
||||
}
|
||||
|
||||
private staleAfter(now: Date) {
|
||||
return new Date(now.getTime() + STATE_TTL);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OneDay, OneKB } from '../../base';
|
||||
import { OneKB } from '../../base';
|
||||
|
||||
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
@@ -14,6 +14,6 @@ export function formatSize(bytes: number, decimals: number = 2): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(ms: number): string {
|
||||
return `${(ms / OneDay).toFixed(0)} days`;
|
||||
export function formatDate(seconds: number): string {
|
||||
return `${(seconds / (24 * 60 * 60)).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
import { getRealtimeInputKey } from '@affine/realtime';
|
||||
import {
|
||||
getRealtimeInputKey,
|
||||
type WorkspaceQuotaStateSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PublicDocMode } from '../../../models';
|
||||
import type { CopilotTranscriptionReader } from '../../../plugins/copilot/transcript';
|
||||
import { CopilotTranscriptRealtimeProvider } from '../../../plugins/copilot/transcript';
|
||||
import type { CurrentUser } from '../../auth';
|
||||
import { CommentRealtimeProvider } from '../../comment/realtime';
|
||||
import { NotificationRealtimeProvider } from '../../notification/realtime';
|
||||
import type { AccessController } from '../../permission';
|
||||
import {
|
||||
DocRole,
|
||||
type PermissionAccess,
|
||||
WorkspaceRole,
|
||||
} from '../../permission';
|
||||
import { QuotaStateRealtimeProvider } from '../../quota/realtime';
|
||||
import { UserRealtimeProvider } from '../../user/realtime';
|
||||
import {
|
||||
DocGrantsRealtimeProvider,
|
||||
DocShareRealtimeProvider,
|
||||
} from '../../workspaces/doc-realtime';
|
||||
import {
|
||||
WorkspaceAccessRealtimeProvider,
|
||||
WorkspaceConfigRealtimeProvider,
|
||||
WorkspaceMembersRealtimeProvider,
|
||||
} from '../../workspaces/realtime';
|
||||
import { RealtimeGateway } from '../gateway';
|
||||
import {
|
||||
realtimeCommentRoom,
|
||||
realtimeDocGrantsRoom,
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
realtimeWorkspaceAccessRoom,
|
||||
realtimeWorkspaceConfigRoom,
|
||||
realtimeWorkspaceEmbeddingProgressRoom,
|
||||
realtimeWorkspaceInviteLinkRoom,
|
||||
realtimeWorkspaceMembersRoom,
|
||||
realtimeWorkspaceQuotaStateRoom,
|
||||
registerRealtimeLiveQuery,
|
||||
} from '../index';
|
||||
import { RealtimePublisher } from '../publisher';
|
||||
@@ -161,6 +190,18 @@ test('room helpers produce stable realtime room names', t => {
|
||||
realtimeWorkspaceEmbeddingProgressRoom('space'),
|
||||
'workspace:space:embedding-progress'
|
||||
);
|
||||
t.is(realtimeWorkspaceAccessRoom('space'), 'workspace:space:access');
|
||||
t.is(realtimeWorkspaceConfigRoom('space'), 'workspace:space:config');
|
||||
t.is(realtimeWorkspaceMembersRoom('space'), 'workspace:space:members');
|
||||
t.is(realtimeWorkspaceInviteLinkRoom('space'), 'workspace:space:invite-link');
|
||||
t.is(
|
||||
realtimeDocShareStateRoom('space', 'doc'),
|
||||
'workspace:space:doc:doc:share-state'
|
||||
);
|
||||
t.is(realtimeDocGrantsRoom('space', 'doc'), 'workspace:space:doc:doc:grants');
|
||||
t.is(realtimeUserProfileRoom('u1'), 'user:u1:profile');
|
||||
t.is(realtimeUserSettingsRoom('u1'), 'user:u1:settings');
|
||||
t.is(realtimeUserAccessTokensRoom('u1'), 'user:u1:access-tokens');
|
||||
t.is(
|
||||
realtimeTranscriptTaskRoom('space', 'task'),
|
||||
'copilot:transcript:space:task'
|
||||
@@ -214,6 +255,581 @@ test('realtime providers expose runtime injection metadata for registry dependen
|
||||
CopilotTranscriptRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
QuotaStateRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
WorkspaceAccessRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
WorkspaceConfigRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
WorkspaceMembersRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata('design:paramtypes', DocShareRealtimeProvider).includes(
|
||||
RealtimeRegistry
|
||||
)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
DocGrantsRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata('design:paramtypes', UserRealtimeProvider).includes(
|
||||
RealtimeRegistry
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace realtime providers register access, config, members and invite link handlers', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const assertions: unknown[] = [];
|
||||
const ac = {
|
||||
user(userId: string) {
|
||||
return {
|
||||
workspace(workspaceId: string) {
|
||||
return {
|
||||
async assert(action: string) {
|
||||
assertions.push({ userId, workspaceId, action });
|
||||
},
|
||||
async permissions() {
|
||||
return {
|
||||
role: WorkspaceRole.Admin,
|
||||
permissions: { 'Workspace.Read': true },
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PermissionAccess;
|
||||
const models = {
|
||||
workspace: {
|
||||
get: async () => ({
|
||||
enableAi: true,
|
||||
enableSharing: false,
|
||||
enableUrlPreview: true,
|
||||
enableDocEmbedding: false,
|
||||
}),
|
||||
},
|
||||
workspaceUser: {
|
||||
search: async () => [],
|
||||
paginate: async () => [
|
||||
[
|
||||
{
|
||||
id: 'invite',
|
||||
type: WorkspaceRole.Collaborator,
|
||||
status: 'Accepted',
|
||||
user: {
|
||||
id: 'u1',
|
||||
name: 'User',
|
||||
email: 'u1@affine.pro',
|
||||
avatarUrl: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
],
|
||||
count: async () => 1,
|
||||
},
|
||||
};
|
||||
const workspaceService = {
|
||||
isTeamWorkspace: async () => true,
|
||||
};
|
||||
const cache = {
|
||||
get: async () => ({ inviteId: 'invite-link' }),
|
||||
ttl: async () => 10,
|
||||
};
|
||||
const url = {
|
||||
link: (path: string) => `https://app.affine.pro${path}`,
|
||||
};
|
||||
|
||||
new WorkspaceAccessRealtimeProvider(
|
||||
ac,
|
||||
workspaceService as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
new WorkspaceConfigRealtimeProvider(
|
||||
ac,
|
||||
models as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
new WorkspaceMembersRealtimeProvider(
|
||||
cache as never,
|
||||
url as never,
|
||||
ac,
|
||||
models as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
|
||||
t.deepEqual(
|
||||
await registry.getRequest('workspace.access.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
}),
|
||||
{
|
||||
access: {
|
||||
role: 'Admin',
|
||||
permissions: { Workspace_Read: true },
|
||||
team: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
t.deepEqual(
|
||||
await registry.getRequest('workspace.config.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
enableAi: true,
|
||||
enableSharing: false,
|
||||
enableUrlPreview: true,
|
||||
enableDocEmbedding: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
t.like(
|
||||
await registry.getRequest('workspace.members.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
take: 1000,
|
||||
}),
|
||||
{ memberCount: 1 }
|
||||
);
|
||||
t.like(
|
||||
await registry.getRequest('workspace.invite-link.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
}),
|
||||
{
|
||||
inviteLink: {
|
||||
link: 'https://app.affine.pro/invite/invite-link',
|
||||
},
|
||||
}
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.access.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceAccessRoom('space')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.config.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceConfigRoom('space')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.members.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceMembersRoom('space')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.invite-link.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceInviteLinkRoom('space')
|
||||
);
|
||||
t.true(
|
||||
assertions.some(
|
||||
item =>
|
||||
JSON.stringify(item) ===
|
||||
JSON.stringify({
|
||||
userId: 'u1',
|
||||
workspaceId: 'space',
|
||||
action: 'Workspace.Users.Read',
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('doc realtime providers register share state and grants handlers', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const assertedActions: string[] = [];
|
||||
const ac = {
|
||||
user(userId: string) {
|
||||
return {
|
||||
doc(workspaceId: string, docId: string) {
|
||||
return {
|
||||
async assert(action: string) {
|
||||
t.deepEqual(
|
||||
{ userId, workspaceId, docId },
|
||||
{
|
||||
userId: 'u1',
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
}
|
||||
);
|
||||
assertedActions.push(action);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PermissionAccess;
|
||||
const models = {
|
||||
doc: {
|
||||
getDocInfo: async () => ({
|
||||
public: true,
|
||||
mode: PublicDocMode.Page,
|
||||
defaultRole: DocRole.Reader,
|
||||
}),
|
||||
},
|
||||
docUser: {
|
||||
findDirectGrantDocIdsByUser: async () => [],
|
||||
paginate: async () => [
|
||||
[
|
||||
{
|
||||
userId: 'u2',
|
||||
type: DocRole.Manager,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
1,
|
||||
],
|
||||
},
|
||||
user: {
|
||||
getWorkspaceUsers: async () => [
|
||||
{
|
||||
id: 'u2',
|
||||
name: 'User 2',
|
||||
email: 'u2@affine.pro',
|
||||
avatarUrl: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const grants = {
|
||||
paginateGrantedUsers: async () => ({
|
||||
totalCount: 1,
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
type: DocRole.Manager,
|
||||
user: {
|
||||
id: 'u2',
|
||||
name: 'User 2',
|
||||
email: 'u2@affine.pro',
|
||||
avatarUrl: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
new DocShareRealtimeProvider(ac, models as never, registry).onModuleInit();
|
||||
new DocGrantsRealtimeProvider(
|
||||
ac,
|
||||
models as never,
|
||||
grants as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
|
||||
t.deepEqual(
|
||||
await registry.getRequest('doc.share-state.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
}),
|
||||
{
|
||||
state: {
|
||||
public: true,
|
||||
mode: 'Page',
|
||||
defaultRole: 'Reader',
|
||||
},
|
||||
}
|
||||
);
|
||||
t.like(
|
||||
await registry.getRequest('doc.grants.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
pagination: { first: 10 },
|
||||
}),
|
||||
{ totalCount: 1 }
|
||||
);
|
||||
t.deepEqual(assertedActions, ['Doc.Read', 'Doc.Users.Read']);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('doc.share-state.changed')
|
||||
.room(user, { workspaceId: 'space', docId: 'doc' }),
|
||||
realtimeDocShareStateRoom('space', 'doc')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('doc.grants.changed')
|
||||
.room(user, { workspaceId: 'space', docId: 'doc' }),
|
||||
realtimeDocGrantsRoom('space', 'doc')
|
||||
);
|
||||
});
|
||||
|
||||
test('user realtime provider snapshots private profile settings and access tokens without plaintext token', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const models = {
|
||||
user: {
|
||||
get: async () => ({
|
||||
id: 'u1',
|
||||
name: 'User',
|
||||
email: 'u1@affine.pro',
|
||||
avatarUrl: null,
|
||||
emailVerifiedAt: new Date(0),
|
||||
password: 'hash',
|
||||
disabled: false,
|
||||
}),
|
||||
},
|
||||
userSettings: {
|
||||
get: async () => ({
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: false,
|
||||
receiveCommentEmail: true,
|
||||
}),
|
||||
},
|
||||
userFeature: {
|
||||
list: async () => ['administrator'],
|
||||
},
|
||||
accessToken: {
|
||||
list: async () => [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
new UserRealtimeProvider(models as never, registry).onModuleInit();
|
||||
|
||||
t.deepEqual(await registry.getRequest('user.profile.get').handle(user, {}), {
|
||||
user: {
|
||||
id: 'u1',
|
||||
name: 'User',
|
||||
email: 'u1@affine.pro',
|
||||
emailVerified: true,
|
||||
hasPassword: true,
|
||||
avatarUrl: null,
|
||||
features: ['Admin'],
|
||||
},
|
||||
});
|
||||
t.deepEqual(
|
||||
await registry
|
||||
.getRequest('user.profile.get')
|
||||
.handle(undefined as never, {}),
|
||||
{ user: null }
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.profile.changed').room(user, {}),
|
||||
realtimeUserProfileRoom('u1')
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.settings.changed').room(user, {}),
|
||||
realtimeUserSettingsRoom('u1')
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.access-tokens.changed').room(user, {}),
|
||||
realtimeUserAccessTokensRoom('u1')
|
||||
);
|
||||
t.deepEqual(await registry.getRequest('user.settings.get').handle(user, {}), {
|
||||
settings: {
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: false,
|
||||
receiveCommentEmail: true,
|
||||
},
|
||||
});
|
||||
t.deepEqual(
|
||||
await registry.getRequest('user.access-tokens.get').handle(user, {}),
|
||||
{
|
||||
tokens: [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('new realtime providers publish changed events from domain events', t => {
|
||||
const published: unknown[][] = [];
|
||||
const publisher = {
|
||||
publish: (...args: unknown[]) => published.push(args),
|
||||
publishChanged: (...args: unknown[]) => published.push(args),
|
||||
} as unknown as RealtimePublisher;
|
||||
|
||||
const workspaceAccess = new WorkspaceAccessRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
workspaceAccess.onMembersUpdated({ workspaceId: 'space' });
|
||||
|
||||
const workspaceConfig = new WorkspaceConfigRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
workspaceConfig.onWorkspaceUpdated({ id: 'space' } as never);
|
||||
|
||||
const workspaceMembers = new WorkspaceMembersRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
workspaceMembers.onInviteLinkCreated({ workspaceId: 'space' });
|
||||
|
||||
const docShare = new DocShareRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
docShare.onPublicStateChanged({ workspaceId: 'space', docId: 'doc' });
|
||||
|
||||
const docGrants = new DocGrantsRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
docGrants.onOwnerChanged({
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
userId: 'u2',
|
||||
});
|
||||
|
||||
const userProvider = new UserRealtimeProvider(
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
userProvider.onUserAccessTokenCreated({ userId: 'u1' });
|
||||
|
||||
t.deepEqual(
|
||||
published.map(args => args[0]),
|
||||
[
|
||||
'workspace.access.changed',
|
||||
'workspace.config.changed',
|
||||
'workspace.invite-link.changed',
|
||||
'doc.share-state.changed',
|
||||
'doc.grants.changed',
|
||||
'user.access-tokens.changed',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('quota realtime provider exposes effective quota state snapshots', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const provider = new QuotaStateRealtimeProvider(
|
||||
{
|
||||
workspaceUser: {
|
||||
getActive: async () => ({ role: 'admin' }),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
reconcileUserQuotaState: async () => ({
|
||||
userId: 'u1',
|
||||
plan: 'pro',
|
||||
sourceEntitlementId: null,
|
||||
blobLimit: 1n,
|
||||
storageQuota: 2n,
|
||||
usedStorageQuota: 3n,
|
||||
historyPeriodSeconds: 4,
|
||||
copilotActionLimit: null,
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
}),
|
||||
reconcileWorkspaceQuotaState: async () => ({
|
||||
workspaceId: 'space',
|
||||
plan: 'team',
|
||||
sourceEntitlementId: null,
|
||||
ownerUserId: 'u1',
|
||||
usesOwnerQuota: false,
|
||||
seatLimit: 5,
|
||||
memberCount: 4,
|
||||
overcapacityMemberCount: 0,
|
||||
blobLimit: 6n,
|
||||
storageQuota: 7n,
|
||||
usedStorageQuota: 8n,
|
||||
historyPeriodSeconds: 9,
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
}),
|
||||
} as never,
|
||||
registry
|
||||
);
|
||||
|
||||
provider.onModuleInit();
|
||||
|
||||
t.deepEqual(
|
||||
await registry.getRequest('user.quota-state.get').handle(user, {}),
|
||||
{
|
||||
state: {
|
||||
userId: 'u1',
|
||||
plan: 'pro',
|
||||
sourceEntitlementId: null,
|
||||
blobLimit: 1,
|
||||
storageQuota: 2,
|
||||
usedStorageQuota: 3,
|
||||
historyPeriodSeconds: 4,
|
||||
copilotActionLimit: null,
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
},
|
||||
}
|
||||
);
|
||||
const workspaceQuotaState = (await registry
|
||||
.getRequest('workspace.quota-state.get')
|
||||
.handle(user, { workspaceId: 'space' })) as {
|
||||
state: WorkspaceQuotaStateSnapshot;
|
||||
};
|
||||
t.is(workspaceQuotaState.state.memberCount, 4);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.quota-state.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceQuotaStateRoom('space')
|
||||
);
|
||||
});
|
||||
|
||||
test('copilot transcript realtime provider registers task live query handlers', async t => {
|
||||
@@ -234,7 +850,7 @@ test('copilot transcript realtime provider registers task live query handlers',
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
const transcript = {
|
||||
async queryTask(
|
||||
userId: string,
|
||||
|
||||
@@ -16,11 +16,22 @@ export { RealtimePublisher } from './publisher';
|
||||
export { RealtimeRegistry } from './registry';
|
||||
export {
|
||||
realtimeCommentRoom,
|
||||
realtimeDocGrantsRoom,
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserQuotaStateRoom,
|
||||
realtimeUserRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
realtimeWorkspaceAccessRoom,
|
||||
realtimeWorkspaceConfigRoom,
|
||||
realtimeWorkspaceDocRoom,
|
||||
realtimeWorkspaceEmbeddingProgressRoom,
|
||||
realtimeWorkspaceInviteLinkRoom,
|
||||
realtimeWorkspaceMembersRoom,
|
||||
realtimeWorkspaceQuotaStateRoom,
|
||||
realtimeWorkspaceRoom,
|
||||
} from './rooms';
|
||||
export type { RealtimeRequestHandler, RealtimeTopicHandler } from './types';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
getRealtimeInputKey,
|
||||
type RealtimeEvent,
|
||||
type RealtimeTopicEventOf,
|
||||
type RealtimeTopicInputOf,
|
||||
type RealtimeTopicName,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@@ -44,6 +46,20 @@ export class RealtimePublisher {
|
||||
}
|
||||
}
|
||||
|
||||
publishChanged<Topic extends RealtimeTopicName>(
|
||||
topic: Topic,
|
||||
input: RealtimeTopicInputOf<Topic>,
|
||||
reason: string,
|
||||
options?: { room?: string }
|
||||
) {
|
||||
this.publish(
|
||||
topic,
|
||||
input,
|
||||
{ changed: true, reason } as RealtimeTopicEventOf<Topic>,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
publishLocal(payload: RealtimePublishPayload) {
|
||||
const handler = this.registry.getTopic(payload.topic);
|
||||
const room = payload.room ?? handler.room(null, payload.input as never);
|
||||
|
||||
@@ -32,3 +32,47 @@ export function realtimeCommentRoom(workspaceId: string, docId: string) {
|
||||
export function realtimeWorkspaceEmbeddingProgressRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'embedding-progress');
|
||||
}
|
||||
|
||||
export function realtimeUserQuotaStateRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'quota-state');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceQuotaStateRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'quota-state');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceAccessRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'access');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceConfigRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'config');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceMembersRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'members');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceInviteLinkRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'invite-link');
|
||||
}
|
||||
|
||||
export function realtimeDocShareStateRoom(workspaceId: string, docId: string) {
|
||||
return realtimeWorkspaceDocRoom(workspaceId, docId, 'share-state');
|
||||
}
|
||||
|
||||
export function realtimeDocGrantsRoom(workspaceId: string, docId: string) {
|
||||
return realtimeWorkspaceDocRoom(workspaceId, docId, 'grants');
|
||||
}
|
||||
|
||||
export function realtimeUserProfileRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'profile');
|
||||
}
|
||||
|
||||
export function realtimeUserSettingsRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'settings');
|
||||
}
|
||||
|
||||
export function realtimeUserAccessTokensRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'access-tokens');
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ import {
|
||||
} from '../doc';
|
||||
import { applyUpdatesWithNative } from '../doc/merge-updates';
|
||||
import {
|
||||
AccessController,
|
||||
type DocAction,
|
||||
PermissionAccess,
|
||||
WorkspaceAction,
|
||||
} from '../permission';
|
||||
import { DocID } from '../utils/doc';
|
||||
@@ -223,7 +223,7 @@ export class SpaceSyncGateway
|
||||
private activeUsersFlushQueued = false;
|
||||
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly event: EventBus,
|
||||
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
||||
private readonly userspace: PgUserspaceDocStorageAdapter,
|
||||
@@ -899,7 +899,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
|
||||
constructor(
|
||||
client: Socket,
|
||||
storage: DocStorageAdapter,
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly docReader: DocReader,
|
||||
private readonly models: Models
|
||||
) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserRealtimeProvider } from './realtime';
|
||||
import {
|
||||
UserManagementResolver,
|
||||
UserResolver,
|
||||
@@ -11,7 +12,12 @@ import {
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, PermissionModule],
|
||||
providers: [UserResolver, UserManagementResolver, UserSettingsResolver],
|
||||
providers: [
|
||||
UserResolver,
|
||||
UserManagementResolver,
|
||||
UserSettingsResolver,
|
||||
UserRealtimeProvider,
|
||||
],
|
||||
controllers: [UserAvatarController],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
AccessTokenSnapshot,
|
||||
CurrentUserProfileSnapshot,
|
||||
UserSettingsSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthenticationRequired, OnEvent, UserNotFound } from '../../base';
|
||||
import { Feature, Models } from '../../models';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { AvailableUserFeatureConfig } from '../features/types';
|
||||
import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
} from '../realtime/rooms';
|
||||
|
||||
const emptyInput = z.object({}).strict();
|
||||
|
||||
function assertAuthenticated(user?: { id: string }) {
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserRealtimeProvider
|
||||
extends AvailableUserFeatureConfig
|
||||
implements OnModuleInit
|
||||
{
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.profile.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
user: user ? await this.getProfile(user.id) : null,
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.profile.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserProfileRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.settings.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
settings: await this.getSettings(assertAuthenticated(user).id),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.settings.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserSettingsRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.access-tokens.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
tokens: await this.getAccessTokens(assertAuthenticated(user).id),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.access-tokens.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserAccessTokensRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
onUserUpdated(user: Events['user.updated']) {
|
||||
this.publisher?.publishChanged('user.profile.changed', {}, 'user-updated', {
|
||||
room: realtimeUserProfileRoom(user.id),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.settings.updated', { suppressError: true })
|
||||
onUserSettingsUpdated({ userId }: Events['user.settings.updated']) {
|
||||
this.publisher?.publishChanged(
|
||||
'user.settings.changed',
|
||||
{},
|
||||
'settings-updated',
|
||||
{ room: realtimeUserSettingsRoom(userId) }
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.created', { suppressError: true })
|
||||
onUserAccessTokenCreated({ userId }: Events['user.access_token.created']) {
|
||||
this.publishAccessTokens(userId, 'access-token-created');
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.revoked', { suppressError: true })
|
||||
onUserAccessTokenRevoked({ userId }: Events['user.access_token.revoked']) {
|
||||
this.publishAccessTokens(userId, 'access-token-revoked');
|
||||
}
|
||||
|
||||
private async getProfile(
|
||||
userId: string
|
||||
): Promise<CurrentUserProfileSnapshot> {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
const current = sessionUser(user);
|
||||
return {
|
||||
id: current.id,
|
||||
name: current.name,
|
||||
email: current.email,
|
||||
emailVerified: current.emailVerified,
|
||||
hasPassword: current.hasPassword,
|
||||
avatarUrl: current.avatarUrl ?? null,
|
||||
features: (await this.models.userFeature.list(userId))
|
||||
.filter(feature => this.availableUserFeatures().has(feature))
|
||||
.map(feature => this.serializeFeature(feature)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeFeature(feature: string) {
|
||||
return (
|
||||
Object.entries(Feature).find(([, value]) => value === feature)?.[0] ??
|
||||
feature
|
||||
);
|
||||
}
|
||||
|
||||
private async getSettings(userId: string): Promise<UserSettingsSnapshot> {
|
||||
return await this.models.userSettings.get(userId);
|
||||
}
|
||||
|
||||
private async getAccessTokens(
|
||||
userId: string
|
||||
): Promise<AccessTokenSnapshot[]> {
|
||||
const tokens = await this.models.accessToken.list(userId);
|
||||
return tokens.map(token => ({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
createdAt: token.createdAt.toISOString(),
|
||||
expiresAt: token.expiresAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
private publishAccessTokens(userId: string, reason: string) {
|
||||
this.publisher?.publishChanged('user.access-tokens.changed', {}, reason, {
|
||||
room: realtimeUserAccessTokensRoom(userId),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { isNil, omitBy } from 'lodash-es';
|
||||
|
||||
import {
|
||||
CannotDeleteOwnAccount,
|
||||
EventBus,
|
||||
type FileUpload,
|
||||
ImageFormatNotSupported,
|
||||
OneMB,
|
||||
@@ -186,7 +187,10 @@ export class UserResolver {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSettingsResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'updateSettings',
|
||||
@@ -199,6 +203,7 @@ export class UserSettingsResolver {
|
||||
) {
|
||||
UserSettingsSchema.parse(input);
|
||||
await this.models.userSettings.set(user.id, input);
|
||||
this.event.emit('user.settings.updated', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { buildPublicRootDoc } from '../../native';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../doc';
|
||||
import { DocReader } from '../doc/reader';
|
||||
import { AccessController, WorkspacePolicyService } from '../permission';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import { CommentAttachmentStorage, WorkspaceBlobStorage } from '../storage';
|
||||
import { DocID } from '../utils/doc';
|
||||
|
||||
@@ -39,8 +39,7 @@ export class WorkspacesController {
|
||||
constructor(
|
||||
private readonly storage: WorkspaceBlobStorage,
|
||||
private readonly commentAttachmentStorage: CommentAttachmentStorage,
|
||||
private readonly ac: AccessController,
|
||||
private readonly workspacePolicy: WorkspacePolicyService,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
||||
private readonly docReader: DocReader,
|
||||
private readonly models: Models
|
||||
@@ -113,7 +112,7 @@ export class WorkspacesController {
|
||||
.workspace(workspaceId)
|
||||
.can('Workspace.Read');
|
||||
const canReadSharedWorkspaceBlobs =
|
||||
await this.workspacePolicy.canReadWorkspaceBySharedDocs(workspaceId);
|
||||
await this.canReadSharedWorkspaceBlobs(workspaceId);
|
||||
if (!canReadWorkspace && !canReadSharedWorkspaceBlobs) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
@@ -163,6 +162,14 @@ export class WorkspacesController {
|
||||
body.pipe(res);
|
||||
}
|
||||
|
||||
private async canReadSharedWorkspaceBlobs(workspaceId: string) {
|
||||
const [sharingEnabled, publicDocs] = await Promise.all([
|
||||
this.models.workspace.allowSharing(workspaceId),
|
||||
this.models.docAccessPolicy.hasPublicExternal(workspaceId),
|
||||
]);
|
||||
return sharingEnabled && publicDocs;
|
||||
}
|
||||
|
||||
// get doc binary
|
||||
@Public()
|
||||
@Get('/:id/docs/:guid')
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { paginate, PaginationInput } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import type { WorkspaceUserType } from '../user';
|
||||
|
||||
@Injectable()
|
||||
export class DocGrantsService {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
async paginateGrantedUsers(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
pagination: PaginationInput
|
||||
) {
|
||||
const [permissions, totalCount] = await this.models.docUser.paginate(
|
||||
workspaceId,
|
||||
docId,
|
||||
pagination
|
||||
);
|
||||
const workspaceUsers = await this.models.user.getWorkspaceUsers(
|
||||
permissions.map(p => p.userId)
|
||||
);
|
||||
const workspaceUsersMap = new Map(
|
||||
workspaceUsers.map(user => [user.id, user])
|
||||
);
|
||||
|
||||
return paginate(
|
||||
permissions.map(permission => {
|
||||
const user = workspaceUsersMap.get(permission.userId);
|
||||
if (!user) {
|
||||
throw new Error(`Doc grant user ${permission.userId} not found`);
|
||||
}
|
||||
return {
|
||||
...permission,
|
||||
user: user as WorkspaceUserType,
|
||||
};
|
||||
}),
|
||||
'createdAt',
|
||||
pagination,
|
||||
totalCount
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
DocShareStateSnapshot,
|
||||
PaginatedDocGrantedUsersSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OnEvent, PaginationInput } from '../../base';
|
||||
import { DocRole, Models, PublicDocMode } from '../../models';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeDocGrantsRoom,
|
||||
realtimeDocShareStateRoom,
|
||||
} from '../realtime/rooms';
|
||||
import { DocGrantsService } from './doc-grants';
|
||||
|
||||
const docInput = z
|
||||
.object({ workspaceId: z.string(), docId: z.string() })
|
||||
.strict();
|
||||
|
||||
@Injectable()
|
||||
export class DocShareRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'doc.share-state.get',
|
||||
input: docInput,
|
||||
handle: async (user, input) => ({
|
||||
state: await this.getShareState(
|
||||
user.id,
|
||||
input.workspaceId,
|
||||
input.docId
|
||||
),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'doc.share-state.changed',
|
||||
input: docInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertRead(user.id, input.workspaceId, input.docId);
|
||||
},
|
||||
room: (_user, input) =>
|
||||
realtimeDocShareStateRoom(input.workspaceId, input.docId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('doc.public_state.changed', { suppressError: true })
|
||||
onPublicStateChanged({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.public_state.changed']) {
|
||||
this.publish(workspaceId, docId, 'public-state-changed');
|
||||
}
|
||||
|
||||
@OnEvent('doc.default_role.changed', { suppressError: true })
|
||||
onDefaultRoleChanged({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.default_role.changed']) {
|
||||
this.publish(workspaceId, docId, 'default-role-changed');
|
||||
}
|
||||
|
||||
private async getShareState(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
): Promise<DocShareStateSnapshot | null> {
|
||||
await this.assertRead(userId, workspaceId, docId);
|
||||
const doc = await this.models.doc.getDocInfo(workspaceId, docId);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
public: doc.public,
|
||||
mode: PublicDocMode[doc.mode],
|
||||
defaultRole: DocRole[doc.defaultRole],
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRead(userId: string, workspaceId: string, docId: string) {
|
||||
await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Read');
|
||||
}
|
||||
|
||||
private publish(workspaceId: string, docId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'doc.share-state.changed',
|
||||
{ workspaceId, docId },
|
||||
reason,
|
||||
{ room: realtimeDocShareStateRoom(workspaceId, docId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocGrantsRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
private readonly grants: DocGrantsService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'doc.grants.get',
|
||||
input: z
|
||||
.object({
|
||||
workspaceId: z.string(),
|
||||
docId: z.string(),
|
||||
pagination: z
|
||||
.object({
|
||||
first: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
after: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
handle: async (user, input) =>
|
||||
this.getGrants(user.id, input.workspaceId, input.docId, {
|
||||
first: input.pagination.first,
|
||||
offset: input.pagination.offset ?? 0,
|
||||
after: input.pagination.after,
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'doc.grants.changed',
|
||||
input: docInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertRead(user.id, input.workspaceId, input.docId);
|
||||
},
|
||||
room: (_user, input) =>
|
||||
realtimeDocGrantsRoom(input.workspaceId, input.docId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('doc.grants.changed', { suppressError: true })
|
||||
onGrantsChanged({ workspaceId, docId }: Events['doc.grants.changed']) {
|
||||
this.publish(workspaceId, docId, 'grants-changed');
|
||||
}
|
||||
|
||||
@OnEvent('doc.owner.changed', { suppressError: true })
|
||||
onOwnerChanged({ workspaceId, docId }: Events['doc.owner.changed']) {
|
||||
this.publish(workspaceId, docId, 'owner-changed');
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
async onUserUpdated(user: Events['user.updated']) {
|
||||
const grants = await this.models.docUser.findDirectGrantDocIdsByUser(
|
||||
user.id
|
||||
);
|
||||
for (const grant of grants) {
|
||||
this.publish(grant.workspaceId, grant.docId, 'user-updated');
|
||||
}
|
||||
}
|
||||
|
||||
private async getGrants(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
input: PaginationInput
|
||||
): Promise<PaginatedDocGrantedUsersSnapshot> {
|
||||
await this.assertRead(userId, workspaceId, docId);
|
||||
const pagination = PaginationInput.decode.transform(input, {} as never);
|
||||
const page = await this.grants.paginateGrantedUsers(
|
||||
workspaceId,
|
||||
docId,
|
||||
pagination
|
||||
);
|
||||
|
||||
return {
|
||||
totalCount: page.totalCount,
|
||||
pageInfo: {
|
||||
endCursor: page.pageInfo.endCursor ?? null,
|
||||
hasNextPage: page.pageInfo.hasNextPage,
|
||||
},
|
||||
edges: page.edges.map(edge => ({
|
||||
node: {
|
||||
role: DocRole[edge.node.type],
|
||||
user: {
|
||||
id: edge.node.user.id,
|
||||
name: edge.node.user.name,
|
||||
email: edge.node.user.email,
|
||||
avatarUrl: edge.node.user.avatarUrl ?? null,
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRead(userId: string, workspaceId: string, docId: string) {
|
||||
await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Users.Read');
|
||||
}
|
||||
|
||||
private publish(workspaceId: string, docId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'doc.grants.changed',
|
||||
{ workspaceId, docId },
|
||||
reason,
|
||||
{ room: realtimeDocGrantsRoom(workspaceId, docId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ declare global {
|
||||
workspaceId: string;
|
||||
quantity: number;
|
||||
};
|
||||
'workspace.invite_link.created': {
|
||||
workspaceId: string;
|
||||
};
|
||||
'workspace.invite_link.revoked': {
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,17 @@ import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserModule } from '../user';
|
||||
import { WorkspacesController } from './controller';
|
||||
import { DocGrantsService } from './doc-grants';
|
||||
import {
|
||||
DocGrantsRealtimeProvider,
|
||||
DocShareRealtimeProvider,
|
||||
} from './doc-realtime';
|
||||
import { WorkspaceEvents } from './event';
|
||||
import {
|
||||
WorkspaceAccessRealtimeProvider,
|
||||
WorkspaceConfigRealtimeProvider,
|
||||
WorkspaceMembersRealtimeProvider,
|
||||
} from './realtime';
|
||||
import {
|
||||
DocHistoryResolver,
|
||||
DocResolver,
|
||||
@@ -44,7 +54,13 @@ import { WorkspaceStatsJob } from './stats.job';
|
||||
DocHistoryResolver,
|
||||
WorkspaceBlobResolver,
|
||||
WorkspaceService,
|
||||
DocGrantsService,
|
||||
WorkspaceEvents,
|
||||
WorkspaceAccessRealtimeProvider,
|
||||
WorkspaceConfigRealtimeProvider,
|
||||
WorkspaceMembersRealtimeProvider,
|
||||
DocShareRealtimeProvider,
|
||||
DocGrantsRealtimeProvider,
|
||||
AdminWorkspaceResolver,
|
||||
WorkspaceStatsJob,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
WORKSPACE_MEMBERS_REQUEST_TAKE_MAX,
|
||||
type WorkspaceAccessSnapshot,
|
||||
type WorkspaceConfigSnapshot,
|
||||
type WorkspaceInviteLinkSnapshot,
|
||||
type WorkspaceMemberSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
Cache,
|
||||
isValidCacheTtl,
|
||||
OnEvent,
|
||||
QueryTooLong,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import type { WorkspaceUserCompat } from '../../models/workspace-user-compat';
|
||||
import type { CurrentUser } from '../auth';
|
||||
import {
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
PermissionAccess,
|
||||
WorkspaceRole,
|
||||
} from '../permission';
|
||||
import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeWorkspaceAccessRoom,
|
||||
realtimeWorkspaceConfigRoom,
|
||||
realtimeWorkspaceInviteLinkRoom,
|
||||
realtimeWorkspaceMembersRoom,
|
||||
} from '../realtime/rooms';
|
||||
import { WorkspaceService } from './service';
|
||||
|
||||
const workspaceInput = z.object({ workspaceId: z.string() }).strict();
|
||||
|
||||
function serializeWorkspaceMember(
|
||||
row: WorkspaceUserCompat
|
||||
): WorkspaceMemberSnapshot {
|
||||
if (!row.user) {
|
||||
throw new Error('Workspace member user is required');
|
||||
}
|
||||
const role = WorkspaceRole[row.type as WorkspaceRole];
|
||||
return {
|
||||
...row.user,
|
||||
avatarUrl: row.user.avatarUrl ?? null,
|
||||
permission: role,
|
||||
role,
|
||||
inviteId: row.id,
|
||||
emailVerified: null,
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceAccessRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.access.get',
|
||||
input: workspaceInput,
|
||||
handle: async (user, input) => ({
|
||||
access: await this.getAccess(user, input.workspaceId),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.access.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(input.workspaceId)
|
||||
.assert('Workspace.Read');
|
||||
},
|
||||
room: (_user, input) => realtimeWorkspaceAccessRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated', { suppressError: true })
|
||||
onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) {
|
||||
this.publish(workspaceId, 'members-updated');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.roleChanged', { suppressError: true })
|
||||
onMemberRoleChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.members.roleChanged']) {
|
||||
this.publish(workspaceId, 'member-role-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.owner.changed', { suppressError: true })
|
||||
onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) {
|
||||
this.publish(workspaceId, 'owner-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.quota_state.changed', { suppressError: true })
|
||||
onWorkspaceQuotaStateChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.quota_state.changed']) {
|
||||
this.publish(workspaceId, 'quota-state-changed');
|
||||
}
|
||||
|
||||
private async getAccess(
|
||||
user: CurrentUser,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceAccessSnapshot> {
|
||||
await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read');
|
||||
const { role, permissions } = await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.permissions();
|
||||
|
||||
return {
|
||||
role: role ? WorkspaceRole[role] : WorkspaceRole[WorkspaceRole.External],
|
||||
permissions: mapPermissionsToGraphqlPermissions(permissions),
|
||||
team: await this.workspaceService.isTeamWorkspace(workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
private publish(workspaceId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.access.changed',
|
||||
{ workspaceId },
|
||||
reason,
|
||||
{ room: realtimeWorkspaceAccessRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceConfigRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.config.get',
|
||||
input: workspaceInput,
|
||||
handle: async (user, input) => ({
|
||||
config: await this.getConfig(user, input.workspaceId),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.config.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertRead(user.id, input.workspaceId);
|
||||
},
|
||||
room: (_user, input) => realtimeWorkspaceConfigRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.updated', { suppressError: true })
|
||||
onWorkspaceUpdated(workspace: Events['workspace.updated']) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.config.changed',
|
||||
{ workspaceId: workspace.id },
|
||||
'workspace-updated',
|
||||
{ room: realtimeWorkspaceConfigRoom(workspace.id) }
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfig(
|
||||
user: CurrentUser,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceConfigSnapshot> {
|
||||
await this.assertRead(user.id, workspaceId);
|
||||
const workspace = await this.models.workspace.get(workspaceId);
|
||||
return {
|
||||
enableAi: Boolean(workspace?.enableAi),
|
||||
enableSharing: Boolean(workspace?.enableSharing),
|
||||
enableUrlPreview: Boolean(workspace?.enableUrlPreview),
|
||||
enableDocEmbedding: Boolean(workspace?.enableDocEmbedding),
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRead(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Settings.Read');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMembersRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly cache: Cache,
|
||||
private readonly url: URLHelper,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.members.get',
|
||||
input: z
|
||||
.object({
|
||||
workspaceId: z.string(),
|
||||
skip: z.number().int().nonnegative().optional(),
|
||||
take: z.number().int().nonnegative().optional(),
|
||||
query: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
handle: async (user, input) => this.getMembers(user, input),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.members.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertMembersRead(user.id, input.workspaceId);
|
||||
},
|
||||
room: (_user, input) => realtimeWorkspaceMembersRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.invite-link.get',
|
||||
input: workspaceInput,
|
||||
handle: async (user, input) => ({
|
||||
inviteLink: await this.getInviteLink(user, input.workspaceId),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.invite-link.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertInviteManage(user.id, input.workspaceId);
|
||||
},
|
||||
room: (_user, input) =>
|
||||
realtimeWorkspaceInviteLinkRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated', { suppressError: true })
|
||||
onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) {
|
||||
this.publishMembers(workspaceId, 'members-updated');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.roleChanged', { suppressError: true })
|
||||
onMemberRoleChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.members.roleChanged']) {
|
||||
this.publishMembers(workspaceId, 'member-role-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.owner.changed', { suppressError: true })
|
||||
onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) {
|
||||
this.publishMembers(workspaceId, 'owner-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.invite_link.created', { suppressError: true })
|
||||
onInviteLinkCreated({
|
||||
workspaceId,
|
||||
}: Events['workspace.invite_link.created']) {
|
||||
this.publishInviteLink(workspaceId, 'invite-link-created');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.invite_link.revoked', { suppressError: true })
|
||||
onInviteLinkRevoked({
|
||||
workspaceId,
|
||||
}: Events['workspace.invite_link.revoked']) {
|
||||
this.publishInviteLink(workspaceId, 'invite-link-revoked');
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
async onUserUpdated(user: Events['user.updated']) {
|
||||
const workspaceIds = await this.models.workspaceUser.getUserWorkspaceIds(
|
||||
user.id
|
||||
);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
this.publishMembers(workspaceId, 'user-updated');
|
||||
}
|
||||
}
|
||||
|
||||
private async getMembers(
|
||||
user: CurrentUser,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
query?: string;
|
||||
}
|
||||
) {
|
||||
await this.assertMembersRead(user.id, input.workspaceId);
|
||||
|
||||
const pagination = {
|
||||
offset: Math.max(input.skip ?? 0, 0),
|
||||
first: Math.min(
|
||||
Math.max(input.take ?? 8, 1),
|
||||
WORKSPACE_MEMBERS_REQUEST_TAKE_MAX
|
||||
),
|
||||
};
|
||||
|
||||
if (input.query) {
|
||||
if (input.query.length > 255) {
|
||||
throw new QueryTooLong({ max: 255 });
|
||||
}
|
||||
const members = await this.models.workspaceUser.search(
|
||||
input.workspaceId,
|
||||
input.query,
|
||||
pagination
|
||||
);
|
||||
return {
|
||||
members: members.map(serializeWorkspaceMember),
|
||||
memberCount: await this.models.workspaceUser.count(input.workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
const [members, memberCount] = await this.models.workspaceUser.paginate(
|
||||
input.workspaceId,
|
||||
pagination
|
||||
);
|
||||
return {
|
||||
members: members.map(serializeWorkspaceMember),
|
||||
memberCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async getInviteLink(
|
||||
user: CurrentUser,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceInviteLinkSnapshot | null> {
|
||||
await this.assertInviteManage(user.id, workspaceId);
|
||||
|
||||
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
||||
const id = await this.cache.get<{ inviteId: string }>(cacheId);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expireTime = await this.cache.ttl(cacheId);
|
||||
if (!isValidCacheTtl(expireTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
link: this.url.link(`/invite/${id.inviteId}`),
|
||||
expireTime: new Date(Date.now() + expireTime * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async assertMembersRead(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Read');
|
||||
}
|
||||
|
||||
private async assertInviteManage(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
}
|
||||
|
||||
private publishMembers(workspaceId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.members.changed',
|
||||
{ workspaceId },
|
||||
reason,
|
||||
{ room: realtimeWorkspaceMembersRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
|
||||
private publishInviteLink(workspaceId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.invite-link.changed',
|
||||
{ workspaceId },
|
||||
reason,
|
||||
{ room: realtimeWorkspaceInviteLinkRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -425,9 +425,6 @@ class AdminUpdateWorkspaceInput extends PartialType(
|
||||
) {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field(() => [Feature], { nullable: true })
|
||||
features?: WorkspaceFeatureName[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -617,28 +614,40 @@ export class AdminWorkspaceResolver {
|
||||
query,
|
||||
pagination
|
||||
);
|
||||
return list.map(({ user, status, type }) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
}));
|
||||
return list.flatMap(({ user, status, type }) =>
|
||||
user
|
||||
? [
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
const [list] = await this.models.workspaceUser.paginate(
|
||||
workspaceId,
|
||||
pagination
|
||||
);
|
||||
return list.map(({ user, status, type }) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
}));
|
||||
return list.flatMap(({ user, status, type }) =>
|
||||
user
|
||||
? [
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => [AdminWorkspaceSharedLink], {
|
||||
@@ -654,7 +663,7 @@ export class AdminWorkspaceResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => AdminWorkspace, {
|
||||
description: 'Update workspace flags and features for admin',
|
||||
description: 'Update workspace flags for admin',
|
||||
nullable: true,
|
||||
})
|
||||
async adminUpdateWorkspace(
|
||||
@@ -662,27 +671,12 @@ export class AdminWorkspaceResolver {
|
||||
input: AdminUpdateWorkspaceInput
|
||||
) {
|
||||
this.assertCloudOnly();
|
||||
const { id, features, ...updates } = input;
|
||||
const { id, ...updates } = input;
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
await this.models.workspace.update(id, updates);
|
||||
}
|
||||
|
||||
if (features) {
|
||||
const current = await this.models.workspaceFeature.list(id);
|
||||
const toAdd = features.filter(feature => !current.includes(feature));
|
||||
const toRemove = current.filter(feature => !features.includes(feature));
|
||||
|
||||
await Promise.all([
|
||||
...toAdd.map(feature =>
|
||||
this.models.workspaceFeature.add(id, feature, 'admin panel update')
|
||||
),
|
||||
...toRemove.map(feature =>
|
||||
this.models.workspaceFeature.remove(id, feature)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
||||
first: 1,
|
||||
skip: 0,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { AccessController, WorkspacePolicyService } from '../../permission';
|
||||
import { PermissionAccess } from '../../permission';
|
||||
import { QuotaService } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import {
|
||||
@@ -125,8 +125,7 @@ class ListedBlob {
|
||||
export class WorkspaceBlobResolver {
|
||||
logger = new Logger(WorkspaceBlobResolver.name);
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly storage: WorkspaceBlobStorage,
|
||||
private readonly models: Models
|
||||
@@ -467,7 +466,10 @@ export class WorkspaceBlobResolver {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.policy.assertCanDeleteBlob(user.id, workspaceId);
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Blobs.Write');
|
||||
|
||||
await this.storage.delete(workspaceId, key, permanently);
|
||||
|
||||
@@ -479,7 +481,10 @@ export class WorkspaceBlobResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.policy.assertCanDeleteBlob(user.id, workspaceId);
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Blobs.Write');
|
||||
|
||||
await this.storage.release(workspaceId);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user