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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@
"is-port-reachable": "3.1.0",
"joi": "17.12.2",
"jschardet": "3.1.3",
"kysely": "0.28.9",
"keyv": "4.5.4",
"knex": "3.1.0",
"lodash": "4.17.21",
Expand Down
10 changes: 10 additions & 0 deletions apps/nestjs-backend/src/cache/redis-native.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ export class RedisNativeService {
await this.client.expire(key, seconds);
}

/**
* Get remaining TTL (in seconds) for a key.
* Redis semantics:
* - -2: key does not exist
* - -1: key exists but has no associated expire
*/
async ttl(key: string): Promise<number> {
return this.client.ttl(key);
}

/**
* Delete a key.
* @param key - Redis key to delete
Expand Down
37 changes: 23 additions & 14 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 @@ -359,20 +380,6 @@ export class AiService {
};
}

async getToolApiKeys(baseId: string) {
const { appConfig } = await this.settingService.getSetting([SettingKey.APP_CONFIG]);
const { spaceId } = await this.prismaService.base.findUniqueOrThrow({
where: { id: baseId },
});
const aiIntegration = await this.prismaService.integration.findFirst({
where: { resourceId: spaceId, type: IntegrationType.AI },
});
const aiIntegrationConfig = aiIntegration?.config ? JSON.parse(aiIntegration.config) : null;
return {
v0ApiKey: aiIntegrationConfig?.appConfig?.apiKey || appConfig?.apiKey,
};
}

async getSimplifiedAIConfig(baseId: string) {
try {
const config = await this.getAIConfig(baseId);
Expand Down Expand Up @@ -554,6 +561,8 @@ export class AiService {
ability: chatModel?.ability,
isInstance,
lgModelKey: chatModel.lg,
mdModelKey: chatModel.md,
smModelKey: chatModel.sm,
};
}

Expand Down
23 changes: 23 additions & 0 deletions apps/nestjs-backend/src/features/ai/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,28 @@ const createOpenAICompatibleWrapper = (
});
};

const createClaudeCodeWrapper = (
options: Parameters<typeof createAnthropic>[0]
): ReturnType<typeof createAnthropic> => {
const baseFetch = createFixingFetch();
const claudeCodeDefaultUa = 'claude-cli/2.1.71 (external, cli)';
return createAnthropic({
...options,
fetch: async (input, init) => {
const initHeaders = (init?.headers ?? {}) as Record<string, string>;
const ua = initHeaders['user-agent'];
return baseFetch(input, {
...init,
headers: {
...init?.headers,
// eslint-disable-next-line @typescript-eslint/naming-convention
'user-agent': ua?.includes('claude-cli') ? ua : claudeCodeDefaultUa,
},
});
},
});
};

export const modelProviders = {
[LLMProviderType.OPENAI]: createOpenAI,
[LLMProviderType.ANTHROPIC]: createAnthropic,
Expand All @@ -105,6 +127,7 @@ export const modelProviders = {
[LLMProviderType.AMAZONBEDROCK]: createAmazonBedrock,
[LLMProviderType.OPENROUTER]: createOpenRouter,
[LLMProviderType.OPENAI_COMPATIBLE]: createOpenAICompatibleWrapper,
[LLMProviderType.CLAUDE_CODE]: createClaudeCodeWrapper,
// AI_GATEWAY is handled separately in ai.service.ts using createGateway from 'ai'
} as const;

Expand Down
6 changes: 3 additions & 3 deletions apps/nestjs-backend/src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ export class AuthService {
}
}

async getTempToken() {
async getTempToken(expiresIn: string = '10m', userId?: string, allowSystemUser?: boolean) {
const payload: IJwtAuthInfo = {
userId: this.cls.get('user.id'),
userId: userId ?? this.cls.get('user.id'),
...(allowSystemUser ? { allowSystemUser: true } : {}),
};
const expiresIn = '10m';
return {
accessToken: await this.jwtService.signAsync(payload, { expiresIn }),
expiresTime: new Date(Date.now() + ms(expiresIn)).toISOString(),
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 @@ -90,7 +90,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_TOKEN_STRATEGY_N
throw new UnauthorizedException('Your account has been deactivated by the administrator');
}

if (user.isSystem) {
if (user.isSystem && payload.allowSystemUser !== true) {
throw new UnauthorizedException('User is system user');
}

Expand Down
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/features/auth/strategies/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type IFromExtractor = (req: Request) => string | null;

export interface IJwtAuthInfo {
userId: string;
allowSystemUser?: boolean;
}

export enum JwtAuthInternalType {
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
Loading
Loading