Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/nestjs-backend/src/features/ai/ai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ export class AiService {
return { type, model, name };
}

/**
* Resolve the model key by matching a body model ID against chatModel lg/md/sm values.
* Model keys are in format type@modelId@name — we compare the modelId segment.
* Falls back to lg if no match is found.
*/
public resolveModelKeyFromBody(
chatModel: { lg?: string; md?: string; sm?: string } | undefined,
bodyModel?: string
): string | undefined {
if (bodyModel) {
const sizes = ['lg', 'md', 'sm'] as const;
for (const size of sizes) {
const key = chatModel?.[size];
if (key && this.parseModelKey(key).model === bodyModel) {
return key;
}
}
}
return chatModel?.lg;
}

/**
* Check if modelKey is an AI Gateway model
* Format: aiGateway@<modelId>@teable
Expand Down Expand Up @@ -554,6 +575,8 @@ export class AiService {
ability: chatModel?.ability,
isInstance,
lgModelKey: chatModel.lg,
mdModelKey: chatModel.md,
smModelKey: chatModel.sm,
};
}

Expand Down
37 changes: 24 additions & 13 deletions apps/nestjs-backend/src/features/auth/guard/permission.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,15 @@ export class PermissionGuard {
resourceId,
permissions
);
// Set user to anonymous for share context
this.cls.set('user', {
id: ANONYMOUS_USER_ID,
name: ANONYMOUS_USER_ID,
email: '',
});
// Preserve logged-in user identity for allowEdit; fall back to anonymous
const currentUserId = this.cls.get('user.id');
if (!currentUserId || isAnonymous(currentUserId)) {
this.cls.set('user', {
id: ANONYMOUS_USER_ID,
name: ANONYMOUS_USER_ID,
email: '',
});
}
this.cls.set('permissions', ownPermissions);
return true;
}
Expand Down Expand Up @@ -297,6 +300,15 @@ export class PermissionGuard {
if (!shareId) {
return undefined;
}
// Skip share path for endpoints without @Permissions (e.g. /user/me),
// otherwise baseSharePermissionCheck throws ForbiddenException.
const permissions = this.reflector.getAllAndOverride<Action[] | undefined>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!permissions?.length) {
return undefined;
}
return await this.baseSharePermissionCheck(context, shareId);
}

Expand Down Expand Up @@ -383,9 +395,10 @@ export class PermissionGuard {
*
* Priority flow:
* 1. RESOURCE-level: exclusively use resource-specific auth (base share > template)
* 2. Early base share check for PUBLIC or anonymous requests when header is present
* 2. Share link check — when share header is present, share permissions are the ceiling
* for ALL users (anonymous or authenticated), so personal role never exceeds the link
* 3. Anonymous user handling (template / USER-level)
* 4. Authenticated user: standard check, with fallback for PUBLIC endpoints
* 4. Authenticated user: standard check, with PUBLIC fallback
*/
protected async permissionCheckWithPublicFallback(
context: ExecutionContext,
Expand All @@ -406,10 +419,8 @@ export class PermissionGuard {
// No valid resource auth header — fall through to normal checks
}

// 2. Early base share check for PUBLIC or anonymous requests
const shouldTryBaseShareEarly =
baseShareHeader && (allowAnonymousType === AllowAnonymousType.PUBLIC || this.isAnonymous());
if (shouldTryBaseShareEarly) {
// 2. Share link — permissions are bounded by the link, regardless of user role
if (baseShareHeader) {
const result = await this.tryBaseSharePermissionCheck(context, baseShareHeader);
if (result !== undefined) return result;
}
Expand All @@ -419,7 +430,7 @@ export class PermissionGuard {
return this.resolveAnonymousPermission(context, allowAnonymousType);
}

// 4. Authenticated user: standard check, with fallback for PUBLIC endpoints
// 4. Authenticated user: standard check, with PUBLIC fallback
try {
return await permissionCheck();
} catch (error) {
Expand Down
21 changes: 20 additions & 1 deletion apps/nestjs-backend/src/features/auth/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { IBaseRole, Action } from '@teable/core';
import {
HttpErrorCode,
IdPrefix,
Role,
TemplatePermissions,
getPermissions,
isAnonymous,
Expand All @@ -27,6 +28,18 @@ interface IBaseNodeCacheItem {

const notAllowedOperationI18nKey = 'httpErrors.permission.notAllowedOperation';

/**
* Permissions that must never be granted via share links,
* even when allowEdit is enabled with a logged-in user.
*/
const SHARE_EXCLUDED_PERMISSIONS = new Set<Action>([
'view|share',
'space|invite_email',
'base|invite_email',
'user|email_read',
'user|integrations',
]);

@Injectable()
export class PermissionService {
private readonly logger = new Logger(PermissionService.name);
Expand Down Expand Up @@ -627,7 +640,13 @@ export class PermissionService {
// Set base share in cls for downstream services to use
this.cls.set('baseShare', { baseId, nodeId });

// Return template permissions (read-only), with record|copy if allowCopy is enabled
// When allowEdit is enabled and user is logged in, grant editor-level permissions
// excluding invite/share/privacy-sensitive actions
if (baseShare.allowEdit && !this.isAnonymous()) {
return getPermissions(Role.Editor).filter((p) => !SHARE_EXCLUDED_PERMISSIONS.has(p));
}

// Otherwise return template permissions (read-only), with record|copy if allowCopy is enabled
const permissions = [...TemplatePermissions];
if (baseShare.allowCopy) {
permissions.push('record|copy');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface IBaseShareInfo {
nodeId: string;
allowSave: boolean | null;
allowCopy: boolean | null;
allowEdit: boolean | null;
}

export interface IJwtBaseShareInfo {
Expand Down Expand Up @@ -84,6 +85,7 @@ export class BaseShareAuthService {
nodeId: share.nodeId,
allowSave: share.allowSave,
allowCopy: share.allowCopy,
allowEdit: share.allowEdit,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class BaseShareOpenController {
@Request() req: Express.Request & { baseShareInfo: IBaseShareInfo }
): Promise<IGetBaseShareVo> {
const shareInfo = req.baseShareInfo;
const { baseId, nodeId, allowSave, allowCopy } = shareInfo;
const { baseId, nodeId, allowSave, allowCopy, allowEdit } = shareInfo;

// Build default URL for redirect
const defaultUrl = await this.buildDefaultUrl(baseId, nodeId);
Expand All @@ -73,6 +73,7 @@ export class BaseShareOpenController {
nodeId,
allowSave,
allowCopy,
allowEdit,
},
defaultUrl,
};
Expand Down
68 changes: 65 additions & 3 deletions apps/nestjs-backend/src/features/base-share/base-share.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { generateShareId, HttpErrorCode } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import type { ICreateBaseShareRo, IUpdateBaseShareRo, IBaseShareVo } from '@teable/openapi';
import { BaseNodeResourceType } from '@teable/openapi';
import { ClsService } from 'nestjs-cls';
import { CustomHttpException } from '../../custom.exception';
import { PerformanceCache, PerformanceCacheService } from '../../performance-cache';
Expand All @@ -24,13 +25,38 @@ export class BaseShareService {
await this.performanceCacheService.del(generateBaseShareListCacheKey(baseId));
}

private async isTableNode(nodeId: string): Promise<boolean> {
const node = await this.prismaService.baseNode.findFirst({
where: { id: nodeId },
select: { resourceType: true },
});
return node?.resourceType === BaseNodeResourceType.Table;
}

/**
* allowEdit and allowSave are mutually exclusive:
* allowEdit=true → allowSave must be false
* allowSave=true → allowEdit must be false
*/
private resolveEditSaveFlags(
allowEdit: boolean | null | undefined,
allowSave: boolean | null | undefined
): { allowEdit: boolean | null; allowSave: boolean | null } {
const edit = allowEdit ?? null;
const save = allowSave ?? null;
if (edit) return { allowEdit: true, allowSave: false };
if (save) return { allowEdit: false, allowSave: true };
return { allowEdit: edit, allowSave: save };
}

private formatBaseShareVo(share: {
baseId: string;
shareId: string;
password: string | null;
nodeId: string;
allowSave: boolean | null;
allowCopy: boolean | null;
allowEdit: boolean | null;
enabled: boolean;
}): IBaseShareVo {
return {
Expand All @@ -40,11 +66,20 @@ export class BaseShareService {
nodeId: share.nodeId,
allowSave: share.allowSave,
allowCopy: share.allowCopy,
allowEdit: share.allowEdit,
enabled: share.enabled,
};
}

async createBaseShare(baseId: string, data: ICreateBaseShareRo): Promise<IBaseShareVo> {
// allowEdit is only valid for table nodes
if (data.allowEdit && !(await this.isTableNode(data.nodeId))) {
throw new CustomHttpException(
'allowEdit is only supported for table nodes',
HttpErrorCode.VALIDATION_ERROR
);
}

const userId = this.cls.get('user.id');

// Check if a share already exists for this node
Expand All @@ -54,13 +89,25 @@ export class BaseShareService {
if (existingShare) {
// If existing share is disabled, re-enable it
if (!existingShare.enabled) {
const resolvedEdit = data.allowEdit ?? existingShare.allowEdit;
if (resolvedEdit && !(await this.isTableNode(data.nodeId))) {
throw new CustomHttpException(
'allowEdit is only supported for table nodes',
HttpErrorCode.VALIDATION_ERROR
);
}
const { allowEdit, allowSave } = this.resolveEditSaveFlags(
resolvedEdit,
data.allowSave ?? existingShare.allowSave
);
const updated = await this.prismaService.baseShare.update({
where: { id: existingShare.id },
data: {
enabled: true,
password: data.password || existingShare.password,
allowSave: data.allowSave ?? existingShare.allowSave,
allowSave,
allowCopy: data.allowCopy ?? existingShare.allowCopy,
allowEdit,
},
});
// Invalidate cache when re-enabling share
Expand All @@ -79,14 +126,16 @@ export class BaseShareService {
}

const shareId = generateShareId();
const { allowEdit, allowSave } = this.resolveEditSaveFlags(data.allowEdit, data.allowSave);
const share = await this.prismaService.baseShare.create({
data: {
baseId,
shareId,
password: data.password || null,
nodeId: data.nodeId,
allowSave: data.allowSave,
allowSave,
allowCopy: data.allowCopy,
allowEdit,
createdBy: userId,
},
});
Expand Down Expand Up @@ -144,12 +193,25 @@ export class BaseShareService {
});
}

if (data.allowEdit && !(await this.isTableNode(share.nodeId))) {
throw new CustomHttpException(
'allowEdit is only supported for table nodes',
HttpErrorCode.VALIDATION_ERROR
);
}

const { allowEdit, allowSave } = this.resolveEditSaveFlags(
data.allowEdit !== undefined ? data.allowEdit : share.allowEdit,
data.allowSave !== undefined ? data.allowSave : share.allowSave
);

const updated = await this.prismaService.baseShare.update({
where: { id: share.id },
data: {
password: data.password !== undefined ? data.password : share.password,
allowSave: data.allowSave !== undefined ? data.allowSave : share.allowSave,
allowSave,
allowCopy: data.allowCopy !== undefined ? data.allowCopy : share.allowCopy,
allowEdit,
enabled: data.enabled !== undefined ? data.enabled : share.enabled,
},
});
Expand Down
Loading
Loading