mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-15 12:22:59 +00:00
Remove role based perms, fetch cosmetics.json from api (#1640)
## Description: * Fetch cosmetics.json from api * Remove all role based perms, we are only using flares now * Created Priviledge refresher which periodically polls /cosmetics.json endpoint. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
+43
-45
@@ -1,19 +1,36 @@
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../core/PatternDecoder";
|
||||
|
||||
export class PrivilegeChecker {
|
||||
export interface PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
base64: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid";
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "invalid";
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
private b64ToPattern: Record<string, Pattern> = {};
|
||||
|
||||
constructor(
|
||||
private cosmetics: Cosmetics,
|
||||
private b64urlDecode: (base64: string) => Uint8Array,
|
||||
) {}
|
||||
) {
|
||||
for (const name in this.cosmetics.patterns) {
|
||||
const pattern = this.cosmetics.patterns[name];
|
||||
this.b64ToPattern[pattern.pattern] = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
isPatternAllowed(
|
||||
base64: string,
|
||||
roles: readonly string[] | undefined,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid" {
|
||||
// Look for the pattern in the cosmetics.json config
|
||||
const found = this.cosmetics.patterns[base64];
|
||||
const found = this.b64ToPattern[base64];
|
||||
if (found === undefined) {
|
||||
try {
|
||||
// Ensure that the pattern will not throw for clients
|
||||
@@ -30,27 +47,9 @@ export class PrivilegeChecker {
|
||||
return "unlisted";
|
||||
}
|
||||
|
||||
const { role_group, name } = found;
|
||||
if (role_group === undefined) {
|
||||
// Pattern has no restrictions
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const groupName of role_group) {
|
||||
if (
|
||||
roles !== undefined &&
|
||||
roles.some((role) =>
|
||||
this.cosmetics.role_groups[groupName]?.includes(role),
|
||||
)
|
||||
) {
|
||||
// Player is in a role group for this pattern
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
flares !== undefined &&
|
||||
(flares.includes(`pattern:${name}`) || flares.includes("pattern:*"))
|
||||
(flares.includes(`pattern:${found.name}`) || flares.includes("pattern:*"))
|
||||
) {
|
||||
// Player has a flare for this pattern
|
||||
return true;
|
||||
@@ -61,7 +60,6 @@ export class PrivilegeChecker {
|
||||
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
roles: readonly string[] | undefined,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "invalid" {
|
||||
if (!flag.startsWith("!")) return "invalid";
|
||||
@@ -78,8 +76,8 @@ export class PrivilegeChecker {
|
||||
for (const segment of segments) {
|
||||
const [layerKey, colorKey] = segment.split("-");
|
||||
if (!layerKey || !colorKey) return "invalid";
|
||||
const layer = this.cosmetics.flag.layers[layerKey];
|
||||
const color = this.cosmetics.flag.color[colorKey];
|
||||
const layer = this.cosmetics.flag?.layers[layerKey];
|
||||
const color = this.cosmetics.flag?.color[colorKey];
|
||||
if (!layer || !color) return "invalid";
|
||||
|
||||
// Super-flare bypasses all restrictions
|
||||
@@ -90,17 +88,9 @@ export class PrivilegeChecker {
|
||||
// Check layer restrictions
|
||||
const layerSpec = layer;
|
||||
let layerAllowed = false;
|
||||
if (!layerSpec.role_group && !layerSpec.flares) {
|
||||
if (!layerSpec.flares) {
|
||||
layerAllowed = true;
|
||||
} else {
|
||||
// By role
|
||||
if (layerSpec.role_group) {
|
||||
const allowedRoles =
|
||||
this.cosmetics.role_groups[layerSpec.role_group] || [];
|
||||
if (roles?.some((r) => allowedRoles.includes(r))) {
|
||||
layerAllowed = true;
|
||||
}
|
||||
}
|
||||
// By flare
|
||||
if (
|
||||
layerSpec.flares &&
|
||||
@@ -117,17 +107,9 @@ export class PrivilegeChecker {
|
||||
// Check color restrictions
|
||||
const colorSpec = color;
|
||||
let colorAllowed = false;
|
||||
if (!colorSpec.role_group && !colorSpec.flares) {
|
||||
if (!colorSpec.flares) {
|
||||
colorAllowed = true;
|
||||
} else {
|
||||
// By role
|
||||
if (colorSpec.role_group) {
|
||||
const allowedRoles =
|
||||
this.cosmetics.role_groups[colorSpec.role_group] || [];
|
||||
if (roles?.some((r) => allowedRoles.includes(r))) {
|
||||
colorAllowed = true;
|
||||
}
|
||||
}
|
||||
// By flare
|
||||
if (
|
||||
colorSpec.flares &&
|
||||
@@ -149,3 +131,19 @@ export class PrivilegeChecker {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
name: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid" {
|
||||
return true;
|
||||
}
|
||||
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "invalid" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { base64url } from "jose";
|
||||
import { Logger } from "winston";
|
||||
import { CosmeticsSchema } from "../core/CosmeticSchemas";
|
||||
import {
|
||||
FailOpenPrivilegeChecker,
|
||||
PrivilegeChecker,
|
||||
PrivilegeCheckerImpl,
|
||||
} from "./Privilege";
|
||||
|
||||
// Refreshes the privilege checker every 5 minutes.
|
||||
// WARNING: This fails open if cosmetics.json is not available.
|
||||
export class PrivilegeRefresher {
|
||||
private privilegeChecker: PrivilegeChecker | null = null;
|
||||
private failOpenPrivilegeChecker: PrivilegeChecker =
|
||||
new FailOpenPrivilegeChecker();
|
||||
|
||||
private log: Logger;
|
||||
|
||||
constructor(
|
||||
private endpoint: string,
|
||||
parentLog: Logger,
|
||||
private refreshInterval: number = 1000 * 60 * 3,
|
||||
) {
|
||||
this.log = parentLog.child({ comp: "privilege-refresher" });
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.log.info(
|
||||
`Starting privilege refresher with interval ${this.refreshInterval}`,
|
||||
);
|
||||
// Add some jitter to the initial load and the interval.
|
||||
setTimeout(() => this.loadPrivilegeChecker(), Math.random() * 1000);
|
||||
setInterval(
|
||||
() => this.loadPrivilegeChecker(),
|
||||
this.refreshInterval + Math.random() * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
public get(): PrivilegeChecker {
|
||||
return this.privilegeChecker ?? this.failOpenPrivilegeChecker;
|
||||
}
|
||||
|
||||
private async loadPrivilegeChecker(): Promise<void> {
|
||||
this.log.info(`Loading privilege checker from ${this.endpoint}`);
|
||||
try {
|
||||
const response = await fetch(this.endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const cosmeticsData = await response.json();
|
||||
const result = CosmeticsSchema.safeParse(cosmeticsData);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
|
||||
}
|
||||
|
||||
this.privilegeChecker = new PrivilegeCheckerImpl(
|
||||
result.data,
|
||||
base64url.decode,
|
||||
);
|
||||
this.log.info(`Privilege checker loaded successfully`);
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-16
@@ -2,14 +2,12 @@ import express, { NextFunction, Request, Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import http from "http";
|
||||
import ipAnonymize from "ip-anonymize";
|
||||
import { base64url } from "jose";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { COSMETICS } from "../core/CosmeticSchemas";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
@@ -25,7 +23,8 @@ import { GameManager } from "./GameManager";
|
||||
import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
import { PrivilegeChecker } from "./Privilege";
|
||||
|
||||
import { PrivilegeRefresher } from "./PrivilegeRefresher";
|
||||
import { initWorkerMetrics } from "./WorkerMetrics";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
@@ -34,7 +33,7 @@ const workerId = parseInt(process.env.WORKER_ID ?? "0");
|
||||
const log = logger.child({ comp: `w_${workerId}` });
|
||||
|
||||
// Worker setup
|
||||
export function startWorker() {
|
||||
export async function startWorker() {
|
||||
log.info(`Worker starting...`);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -46,12 +45,16 @@ export function startWorker() {
|
||||
|
||||
const gm = new GameManager(config, log);
|
||||
|
||||
const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode);
|
||||
|
||||
if (config.otelEnabled()) {
|
||||
initWorkerMetrics(gm);
|
||||
}
|
||||
|
||||
const privilegeRefresher = new PrivilegeRefresher(
|
||||
config.jwtIssuer() + "/cosmetics.json",
|
||||
log,
|
||||
);
|
||||
privilegeRefresher.start();
|
||||
|
||||
// Middleware to handle /wX path prefix
|
||||
app.use((req, res, next) => {
|
||||
// Extract the original path without the worker prefix
|
||||
@@ -396,11 +399,9 @@ export function startWorker() {
|
||||
// Check if the flag is allowed
|
||||
if (clientMsg.flag !== undefined) {
|
||||
if (clientMsg.flag.startsWith("!")) {
|
||||
const allowed = privilegeChecker.isCustomFlagAllowed(
|
||||
clientMsg.flag,
|
||||
roles,
|
||||
flares,
|
||||
);
|
||||
const allowed = privilegeRefresher
|
||||
.get()
|
||||
.isCustomFlagAllowed(clientMsg.flag, flares);
|
||||
if (allowed !== true) {
|
||||
log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
|
||||
ws.close(1002, `Custom flag ${allowed}`);
|
||||
@@ -411,11 +412,9 @@ export function startWorker() {
|
||||
|
||||
// Check if the pattern is allowed
|
||||
if (clientMsg.pattern !== undefined) {
|
||||
const allowed = privilegeChecker.isPatternAllowed(
|
||||
clientMsg.pattern,
|
||||
roles,
|
||||
flares,
|
||||
);
|
||||
const allowed = privilegeRefresher
|
||||
.get()
|
||||
.isPatternAllowed(clientMsg.pattern, flares);
|
||||
if (allowed !== true) {
|
||||
log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`);
|
||||
ws.close(1002, `Pattern ${allowed}`);
|
||||
|
||||
Reference in New Issue
Block a user