Feature: Throttle fetch to be at most every 30s and handle permissions (#4)

This commit is contained in:
Timothee Guerin 2019-05-20 08:44:04 -07:00 коммит произвёл GitHub
Родитель d90c0b8daf
Коммит ca4516703b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
32 изменённых файлов: 644 добавлений и 173 удалений

Просмотреть файл

@ -11,10 +11,10 @@ steps:
inputs:
versionSpec: "10.x"
- script: npm ci
- script: npm -s ci
displayName: "Install dependencies"
- script: npm run test
- script: npm run -s test:ci
displayName: "Test"
- task: PublishTestResults@2
@ -28,5 +28,5 @@ steps:
summaryFileLocation: "$(System.DefaultWorkingDirectory)/**/*coverage.xml"
reportDirectory: "$(System.DefaultWorkingDirectory)/**/coverage"
- script: npm run lint
- script: npm run -s lint
displayName: "Lint"

4
.gitignore поставляемый
Просмотреть файл

@ -59,4 +59,6 @@ jspm_packages/
bin/
buildcache/
tmp/
tmp/
# Junit
test-results*

Просмотреть файл

@ -1,4 +1,5 @@
{
"extends": "../tsconfig.json",
"exclude": ["node_modules", "bin", "test", "**/*.test.ts"]
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "bin", "test", "src/**/*.test.ts"]
}

Просмотреть файл

@ -17,7 +17,7 @@ const config = {
},
},
setupFiles: ["<rootDir>/test/jest-setup.ts"],
testMatch: ["**/*.test.ts"],
testMatch: ["<rootDir>/src/**/*.test.ts"],
verbose: true,
testEnvironment: "node",
};

82
package-lock.json сгенерированный
Просмотреть файл

@ -484,6 +484,15 @@
"@babel/types": "^7.3.0"
}
},
"@types/basic-auth": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@types/basic-auth/-/basic-auth-1.1.2.tgz",
"integrity": "sha512-NzkkcC+gkkILWaBi3+/z/3do6Ybk6TWeTqV5zCVXmG2KaBoT5YqlJvfqP44HCyDA+Cu58pp7uKAxy/G58se/TA==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/convict": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@types/convict/-/convict-4.2.1.tgz",
@ -535,6 +544,15 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz",
"integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA=="
},
"@types/node-fetch": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.3.4.tgz",
"integrity": "sha512-ZwGXz5osL88SF+jlbbz0WJlINlOZHoSWPrLytQRWRdB6j/KVLup1OoqIxnjO6q9ToqEEP3MZFzJCotgge+IiRw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/nodegit": {
"version": "0.24.6",
"resolved": "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.24.6.tgz",
@ -881,6 +899,14 @@
}
}
},
"basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"requires": {
"safe-buffer": "5.1.2"
}
},
"bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@ -3646,6 +3672,35 @@
"throat": "^4.0.0"
}
},
"jest-junit": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-6.4.0.tgz",
"integrity": "sha512-GXEZA5WBeUich94BARoEUccJumhCgCerg7mXDFLxWwI2P7wL3Z7sGWk+53x343YdBLjiMR9aD/gYMVKO+0pE4Q==",
"dev": true,
"requires": {
"jest-validate": "^24.0.0",
"mkdirp": "^0.5.1",
"strip-ansi": "^4.0.0",
"xml": "^1.0.1"
},
"dependencies": {
"ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
"dev": true
},
"strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
"dev": true,
"requires": {
"ansi-regex": "^3.0.0"
}
}
}
},
"jest-leak-detector": {
"version": "24.8.0",
"resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-24.8.0.tgz",
@ -4103,21 +4158,6 @@
"es5-ext": "~0.10.2"
}
},
"make-dir": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz",
"integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==",
"requires": {
"semver": "^6.0.0"
},
"dependencies": {
"semver": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz",
"integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ=="
}
}
},
"make-error": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.5.tgz",
@ -4421,9 +4461,9 @@
"dev": true
},
"node-fetch": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.5.0.tgz",
"integrity": "sha512-YuZKluhWGJwCcUu4RlZstdAxr8bFfOVHakc1mplwHkk8J+tqM1Y5yraYvIUpeX8aY7+crCwiELJq7Vl0o0LWXw=="
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
},
"node-gyp": {
"version": "4.0.0",
@ -6426,6 +6466,12 @@
"async-limiter": "~1.0.0"
}
},
"xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=",
"dev": true
},
"xml-name-validator": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",

Просмотреть файл

@ -11,6 +11,7 @@
"start:dev": "concurrently --handle-input \"wait-on bin/main.js && nodemon\" \"tsc -w -p tsconfig.build.json\" ",
"start:debug": "nodemon --config nodemon-debug.json",
"test": "jest",
"test:ci": "jest --ci --reporters=default --reporters=jest-junit",
"lint": "tslint -p tsconfig.json",
"test:watch": "jest --watch --coverage=false --config ./config/jest.dev.config.js",
"swagger:gen": "node ./bin/scripts/generate-swagger-specs.js"
@ -26,11 +27,14 @@
},
"homepage": "https://github.com/Azure/git-rest-api#readme",
"devDependencies": {
"@types/basic-auth": "^1.1.2",
"@types/convict": "^4.2.1",
"@types/jest": "^24.0.13",
"@types/node-fetch": "^2.3.4",
"@types/nodegit": "^0.24.6",
"concurrently": "^4.1.0",
"jest": "^24.8.0",
"jest-junit": "^6.4.0",
"prettier": "^1.17.1",
"swagger-ui-express": "^4.0.3",
"ts-jest": "^24.0.2",
@ -45,11 +49,16 @@
"@nestjs/platform-express": "^6.2.0",
"@nestjs/swagger": "^3.0.2",
"@types/node": "^12.0.2",
"basic-auth": "^2.0.1",
"class-validator": "^0.9.1",
"convict": "^5.0.0",
"make-dir": "^3.0.0",
"node-fetch": "^2.6.0",
"nodegit": "^0.24.3",
"reflect-metadata": "^0.1.13",
"rxjs": "^6.5.2"
},
"jest-junit": {
"outputDirectory": ".",
"outputName": "test-results.xml"
}
}

Просмотреть файл

@ -2,11 +2,30 @@ import { Module } from "@nestjs/common";
import { Configuration } from "./config";
import { AppController, BranchesController, HealthCheckController } from "./controllers";
import { AppService, BranchService, FSService, PermissionService, RepoService } from "./services";
import {
AppService,
BranchService,
FSService,
GitFetchService,
HttpService,
PermissionCacheService,
PermissionService,
RepoService,
} from "./services";
@Module({
imports: [],
controllers: [AppController, HealthCheckController, BranchesController],
providers: [AppService, RepoService, FSService, BranchService, PermissionService, Configuration],
providers: [
AppService,
RepoService,
FSService,
BranchService,
PermissionService,
GitFetchService,
PermissionCacheService,
HttpService,
Configuration,
],
})
export class AppModule {}

Просмотреть файл

@ -27,7 +27,7 @@ describe("BranchController", () => {
});
it("list the branches", async () => {
const auth = new RepoAuth({});
const auth = new RepoAuth();
const branches = await controller.list("github.com/Azure/git-rest-api", auth);
expect(branches).toEqual([b1, b2]);

Просмотреть файл

@ -1 +1,2 @@
export * from "./repo-auth";
export * from "./repo-auth-decorators";

Просмотреть файл

@ -0,0 +1,40 @@
import { BadRequestException, createParamDecorator } from "@nestjs/common";
import { ApiBadRequestResponse, ApiImplicitHeaders } from "@nestjs/swagger";
import { AUTH_HEADERS, RepoAuth } from "./repo-auth";
/**
* Auth param decorator for controller to inject the repo auth object
*/
export const Auth = createParamDecorator(
(_, req): RepoAuth => {
const repoAuth = RepoAuth.fromHeaders(req.headers);
if (repoAuth) {
return repoAuth;
} else {
throw new BadRequestException("Repository authorization is malformed");
}
},
);
/**
* Helper to add on methods using the Auth parameter for the swagger specs to be generated correctly
*/
export function ApiHasPassThruAuth(): MethodDecorator {
const implicitHeaders = ApiImplicitHeaders(
Object.values(AUTH_HEADERS).map(header => {
return {
name: header,
required: false,
};
}),
);
const badRequestResponse = ApiBadRequestResponse({
description: "When the api request header is malformed",
});
return (...args) => {
implicitHeaders(...args);
badRequestResponse(...args);
};
}

Просмотреть файл

@ -5,13 +5,37 @@ import { RepoAuth } from "./repo-auth";
describe("RepoAuth", () => {
describe("Model", () => {
it("doesn't generated any creds when no options are passed", () => {
expect(new RepoAuth({}).toCreds()).toBeUndefined();
expect(new RepoAuth().toCreds()).toBeUndefined();
});
it("returns undefined if invalid basic header", () => {
expect(RepoAuth.fromHeaders({ "x-authorization": "Basi invlid" })).toBeUndefined();
});
it("doesn't generate some basic password creds when using oath token", () => {
expect(new RepoAuth({ token: "token-1" }).toCreds()).toEqual(
expect(RepoAuth.fromHeaders({ "x-github-token": "token-1" })!.toCreds()).toEqual(
Cred.userpassPlaintextNew("token-1", "x-oauth-basic"),
);
});
it("should return different hashes per cred", () => {
const hash1 = new RepoAuth({ username: "foo", password: "bar" }).hash();
const hash2 = new RepoAuth({ username: "foo2", password: "bar" }).hash();
const hash3 = new RepoAuth({ username: "foo", password: "bar2" }).hash();
expect(hash1).not.toBe(hash2);
expect(hash2).not.toBe(hash3);
expect(hash1).not.toBe(hash3);
expect(hash1).toBe(
"6bd974a6ce1305174fff5df0cefd2025005861ad19332177290f10af00e0b5dee8e563417bc6f509e50527d35ec48986593d347cfc41069e8bfad24e70c5eabb",
);
expect(hash2).toBe(
"cd6f6487398b42e04b68676f8567d4c9494f49347862272ebe7cba3ce5c9fa2c05cdc3ee302ae42bbb00632e7ad78f1b0ecd1b37634ffcae3756b427e7c8080b",
);
expect(hash3).toBe(
"34f5826bdf4f7863ede840bfc394a585c233862eee7d6c6dcdc0287b98626fae43f91f41700144c22207372b24ecdec35f6b1d4ebd882d2a5b7b25918b3c18aa",
);
});
});
});

Просмотреть файл

@ -1,45 +1,84 @@
import { createParamDecorator } from "@nestjs/common";
import { ApiImplicitHeaders } from "@nestjs/swagger";
import basicAuth from "basic-auth";
import { Cred } from "nodegit";
export interface IRepoAuth {
token?: string;
}
import { SecureUtils } from "../../utils";
export const AUTH_HEADERS = {
generic: "x-authorization",
github: "x-github-token",
};
/**
* Class that handle repository authentication
* Support generic server auth using the `x-authentication` header which is just a pass thru to the repo
* Supoort a few helper
* - `x-github-token`: Pass a github token to authenticate
*/
export class RepoAuth {
private token: string | undefined;
private readonly username?: string;
private readonly password?: string;
constructor(obj: IRepoAuth) {
this.token = obj.token;
constructor(obj?: { username: string; password: string }) {
if (obj) {
this.username = obj.username;
this.password = obj.password;
}
}
/**
* Get a repo auth instance from the header
* @param headers: Header string map
*
* @returns {RepoAuth} if it manage to create an object
* @returns {undefined} if the authorization headers are invalid. This should result in an error. This is not the same as not providing any headers. This means the headers were invalid
*/
public static fromHeaders(headers: { [key: string]: string }): RepoAuth | undefined {
if (headers[AUTH_HEADERS.generic]) {
const auth = parseAuthorizationHeader(headers[AUTH_HEADERS.generic]);
if (!auth) {
return undefined;
}
return new RepoAuth(auth);
} else if (headers[AUTH_HEADERS.github]) {
return new RepoAuth({
username: headers[AUTH_HEADERS.github],
password: "x-oauth-basic",
});
}
return new RepoAuth();
}
public toCreds(): Cred | undefined {
if (this.token) {
return Cred.userpassPlaintextNew(this.token, "x-oauth-basic");
} else {
return undefined;
if (this.username && this.password) {
return Cred.userpassPlaintextNew(this.username, this.password);
}
return undefined;
}
public toAuthorizationHeader(): string | undefined {
if (this.username && this.password) {
const header = `${this.username}:${this.password}`;
return `Basic ${Buffer.from(header).toString("base64")}`;
}
return undefined;
}
public hash(): string | undefined {
const header = this.toAuthorizationHeader();
return header ? SecureUtils.sha512(header) : undefined;
}
}
const TOKEN_HEADER = "x-oauth-basic";
export const Auth = createParamDecorator(
(_, req): RepoAuth => {
return new RepoAuth({
token: req.headers[TOKEN_HEADER],
});
},
);
/**
* Helper to add on methods using the Auth parameter for the swagger specs to be generated correctly
* Parse the authorization header into username and password.
* Needs to be in this format for now
* `Basic [base64Encoded(username:password)`
*/
export function ApiHasPassThruAuth() {
return ApiImplicitHeaders([
{
name: TOKEN_HEADER,
required: false,
},
]);
function parseAuthorizationHeader(header: string) {
const result = basicAuth.parse(header);
if (!result) {
return undefined;
}
return { username: result.name, password: result.pass };
}

1
src/definitions.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
type StringMap<T> = { [key: string]: T };

8
src/models/local-repo.ts Normal file
Просмотреть файл

@ -0,0 +1,8 @@
import { Repository } from "nodegit";
import { GitRemotePermission } from "../services";
export interface LocalRepo {
repo: Repository;
permission: GitRemotePermission;
}

Просмотреть файл

@ -1,19 +1,19 @@
import { Injectable } from "@nestjs/common";
import fs from "fs";
import makeDir from "make-dir";
import util from "util";
const fsPromises = {
exists: util.promisify(fs.exists),
};
@Injectable()
export class FSService {
public async exists(path: string): Promise<boolean> {
return fsPromises.exists(path);
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
public async makeDir(path: string): Promise<string> {
return makeDir(path);
public async mkdir(path: string): Promise<string> {
await fs.promises.mkdir(path, { recursive: true });
return path;
}
}

Просмотреть файл

@ -0,0 +1,97 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Clone, Fetch, FetchOptions, Repository } from "nodegit";
import path from "path";
import { FSService } from "../fs";
import { GitBaseOptions } from "../repo";
export function credentialsCallback(options: GitBaseOptions) {
return () => {
if (options.auth) {
return options.auth.toCreds();
}
return undefined;
};
}
export const defaultFetchOptions: FetchOptions = {
downloadTags: 0,
prune: Fetch.PRUNE.GIT_FETCH_PRUNE,
};
export const repoCacheFolder = path.join("./tmp", "repos");
const FETCH_TIMEOUT = 30_000; // 30s;
@Injectable()
export class GitFetchService {
private currentFetches = new Map<string, Promise<Repository>>();
private lastFetch = new Map<string, number>();
private cacheReady: Promise<string>;
constructor(fs: FSService) {
this.cacheReady = fs.mkdir(repoCacheFolder);
}
public async fetch(remote: string, repo: Repository, options: GitBaseOptions): Promise<Repository> {
if (await this.needToFetch(remote)) {
return this.ensureSingleFetch(remote, () => this.fetchAll(remote, repo, options).then(() => repo));
}
return repo;
}
private ensureSingleFetch(remote: string, callback: () => Promise<Repository>): Promise<Repository> {
let promise = this.currentFetches.get(remote);
if (!promise) {
promise = callback().then(repo => {
this.currentFetches.delete(remote);
return repo;
});
this.currentFetches.set(remote, promise);
}
return promise;
}
public async clone(remote: string, repoPath: string, options: GitBaseOptions): Promise<Repository> {
await this.cacheReady;
try {
return await Clone.clone(`https://${remote}`, repoPath, {
fetchOpts: {
...defaultFetchOptions,
callbacks: {
credentials: credentialsCallback(options),
},
},
});
} catch {
throw new NotFoundException();
}
}
private async fetchAll(remote: string, repo: Repository, options: GitBaseOptions) {
try {
await repo.fetchAll({
...defaultFetchOptions,
callbacks: {
credentials: credentialsCallback(options),
},
});
this.lastFetch.set(remote, new Date().getTime());
} catch {
throw new NotFoundException();
}
}
/**
* If we need to fetch the given remote
*/
private async needToFetch(remote: string): Promise<boolean> {
const lastFetch = this.lastFetch.get(remote);
if (!lastFetch) {
return true;
}
const now = new Date().getTime();
return now - lastFetch > FETCH_TIMEOUT;
}
}

Просмотреть файл

@ -0,0 +1 @@
export * from "./git-fetch.service";

Просмотреть файл

@ -0,0 +1,9 @@
import { Injectable } from "@nestjs/common";
import fetch, { RequestInit, Response } from "node-fetch";
@Injectable()
export class HttpService {
public fetch(uri: string, init?: RequestInit): Promise<Response> {
return fetch(uri, init);
}
}

Просмотреть файл

@ -0,0 +1 @@
export * from "./http.service";

Просмотреть файл

@ -3,3 +3,5 @@ export * from "./branch";
export * from "./fs";
export * from "./repo";
export * from "./permission";
export * from "./git-fetch";
export * from "./http";

1
src/services/permission/cache/index.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
export * from "./permission-cache.service";

54
src/services/permission/cache/permission-cache.service.test.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1,54 @@
import { RepoAuth } from "../../../core";
import { GitRemotePermission } from "../permissions";
import { PermissionCacheService } from "./permission-cache.service";
const remote = "github.com/Azure/git-rest-specs";
const auth = new RepoAuth({ username: "token-1", password: "x-oauth-token" });
describe("PermissionService", () => {
let service: PermissionCacheService;
beforeEach(() => {
jest.clearAllTimers();
jest.useFakeTimers();
service = new PermissionCacheService();
});
it("returns undefined when permission has not been set", () => {
expect(service.get(auth, remote)).toBeUndefined();
});
it("returns none when permission is set to none", () => {
service.set(auth, remote, GitRemotePermission.None);
expect(service.get(auth, remote)).toBe(GitRemotePermission.None);
});
it("set a permission", () => {
service.set(auth, remote, GitRemotePermission.Read);
expect(service.get(auth, remote)).toBe(GitRemotePermission.Read);
});
it("clear permission after a timeout", () => {
const now = Date.now();
service.set(auth, remote, GitRemotePermission.Read);
expect(service.get(auth, remote)).toBe(GitRemotePermission.Read);
mockDate(now + 50_000);
// Should still be cached
expect(service.get(auth, remote)).toBe(GitRemotePermission.Read);
mockDate(now + 60_010);
expect(service.get(auth, remote)).toBeUndefined();
});
it("doesn't get permission from another key", () => {
service.set(auth, remote, GitRemotePermission.Read);
expect(service.get(new RepoAuth(), remote)).toBeUndefined();
expect(service.get(new RepoAuth({ username: "foo", password: "x-oauth-token" }), remote)).toBeUndefined();
expect(service.get(new RepoAuth({ username: "token-1", password: "x-other-token" }), remote)).toBeUndefined();
});
});
function mockDate(now: number) {
jest.spyOn(global.Date, "now").mockImplementation(() => now);
}

41
src/services/permission/cache/permission-cache.service.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1,41 @@
import { Injectable } from "@nestjs/common";
import { RepoAuth } from "../../../core";
import { GitRemotePermission } from "../permissions";
const TOKEN_INVALIDATE_TIMEOUT = 60_000; // 60s
export interface CachedPermission {
lastSync: number; // Date
permission: GitRemotePermission;
}
@Injectable()
export class PermissionCacheService {
private tokenPermissions = new Map<string, CachedPermission>();
public get(auth: RepoAuth, remote: string): GitRemotePermission | undefined {
const key = this.getMapKey(auth, remote);
const cache = this.tokenPermissions.get(key);
if (cache === undefined) {
return undefined;
}
const now = Date.now();
if (now - cache.lastSync > TOKEN_INVALIDATE_TIMEOUT) {
this.tokenPermissions.delete(key);
return undefined;
}
return cache.permission;
}
public set(auth: RepoAuth, remote: string, permission: GitRemotePermission): GitRemotePermission {
const key = this.getMapKey(auth, remote);
this.tokenPermissions.set(key, { permission, lastSync: Date.now() });
return permission;
}
private getMapKey(auth: RepoAuth, remote: string) {
return `${auth.hash()}/${remote}`;
}
}

Просмотреть файл

@ -1 +1,3 @@
export * from "./permission.service";
export * from "./cache";
export * from "./permissions";

Просмотреть файл

@ -1,28 +1,103 @@
import { PermissionService, TokenPermission } from "./permission.service";
import { RepoAuth } from "../../core";
import { PermissionCacheService } from "./cache";
import { PermissionService } from "./permission.service";
import { GitRemotePermission } from "./permissions";
const remote = "github.com/Azure/some-repo";
const remotePrivate = "github.com/Azure/some-private";
const auth = new RepoAuth({ username: "token-1", password: "x-oauth-token" });
const remote = "github.com/Azure/git-rest-specs";
describe("PermissionService", () => {
let service: PermissionService;
let cache: PermissionCacheService;
const httpSpy = {
fetch: jest.fn(uri => {
if (uri.includes(remote) && uri.includes("git-upload-pack")) {
return { status: 200 };
} else if (uri.includes(remotePrivate)) {
return { status: 200 };
}
return {
status: 404,
};
}),
};
beforeEach(() => {
jest.clearAllTimers();
jest.useFakeTimers();
service = new PermissionService();
jest.clearAllMocks();
cache = new PermissionCacheService();
service = new PermissionService(cache, httpSpy as any);
});
it("returns undefined when permission has not been set", () => {
expect(service.getTokenPermission("token-1", remote)).toBeUndefined();
it("returns the permission in the cache if its there", async () => {
cache.set(auth, remote, GitRemotePermission.Read);
expect(await service.get(auth, remote)).toBe(GitRemotePermission.Read);
});
it("set a permission", () => {
service.setTokenPermission("token-1", remote, TokenPermission.Read);
expect(service.getTokenPermission("token-1", remote)).toBe(TokenPermission.Read);
it("returns none when permission is set to none", async () => {
service.set(auth, remote, GitRemotePermission.None);
expect(await service.get(auth, remote)).toBe(GitRemotePermission.None);
});
it("clear permission after a timeout", () => {
service.setTokenPermission("token-1", remote, TokenPermission.Read);
expect(service.getTokenPermission("token-1", remote)).toBe(TokenPermission.Read);
jest.runTimersToTime(60_000);
expect(service.getTokenPermission("token-1", remote)).toBeUndefined();
describe("when permission are not cached", () => {
it("try write endpoint", async () => {
const permission = await service.get(auth, remotePrivate);
expect(permission).toBe(GitRemotePermission.Write);
expect(cache.get(auth, remotePrivate)).toBe(GitRemotePermission.Write);
expect(httpSpy.fetch).toHaveBeenCalledTimes(1);
expect(httpSpy.fetch).toHaveBeenCalledWith("https://github.com/Azure/some-private.git/git-receive-pack", {
headers: {
Authorization: auth.toAuthorizationHeader(),
},
});
});
it("try write endpoint then read if can't write", async () => {
const permission = await service.get(auth, remote);
expect(permission).toBe(GitRemotePermission.Read);
expect(cache.get(auth, remote)).toBe(GitRemotePermission.Read);
expect(httpSpy.fetch).toHaveBeenCalledTimes(2);
expect(httpSpy.fetch).toHaveBeenCalledWith("https://github.com/Azure/some-repo.git/git-receive-pack", {
headers: {
Authorization: auth.toAuthorizationHeader(),
},
});
expect(httpSpy.fetch).toHaveBeenCalledWith("https://github.com/Azure/some-repo.git/git-upload-pack", {
headers: {
Authorization: auth.toAuthorizationHeader(),
},
});
});
it("try write and read if has no permission", async () => {
const permission = await service.get(auth, "github.com/Azure/other-repo-no-permissions");
expect(permission).toBe(GitRemotePermission.None);
expect(cache.get(auth, "github.com/Azure/other-repo-no-permissions")).toBe(GitRemotePermission.None);
expect(httpSpy.fetch).toHaveBeenCalledTimes(2);
expect(httpSpy.fetch).toHaveBeenCalledWith(
"https://github.com/Azure/other-repo-no-permissions.git/git-receive-pack",
{
headers: {
Authorization: auth.toAuthorizationHeader(),
},
},
);
expect(httpSpy.fetch).toHaveBeenCalledWith(
"https://github.com/Azure/other-repo-no-permissions.git/git-upload-pack",
{
headers: {
Authorization: auth.toAuthorizationHeader(),
},
},
);
});
});
});

Просмотреть файл

@ -1,54 +1,67 @@
import { Injectable } from "@nestjs/common";
import { SecureUtils } from "../../utils";
export enum TokenPermission {
None,
Read,
Write,
}
const TOKEN_INVALIDATE_TIMEOUT = 60_000; // 60s
import { RepoAuth } from "../../core";
import { RepoUtils } from "../../utils";
import { HttpService } from "../http";
import { PermissionCacheService } from "./cache";
import { GitRemotePermission } from "./permissions";
@Injectable()
export class PermissionService {
private tokenPermissions = new Map<string, TokenPermission>();
private timeouts = new Map<string, NodeJS.Timeout>();
constructor(private cache: PermissionCacheService, private http: HttpService) {}
public getTokenPermission(token: string, remote: string): TokenPermission | undefined {
return this.tokenPermissions.get(this.getMapKey(token, remote));
public async get(auth: RepoAuth, remote: string): Promise<GitRemotePermission> {
const cached = this.cache.get(auth, remote);
if (cached !== undefined) {
return cached;
}
return this.retrievePermissions(auth, remote);
}
public setTokenPermission(token: string, remote: string, permission: TokenPermission) {
const key = this.getMapKey(token, remote);
this.tokenPermissions.set(key, permission);
this.timeoutPermission(key);
public set(auth: RepoAuth, remote: string, permission: GitRemotePermission) {
return this.cache.set(auth, remote, permission);
}
/**
* Remove the cached permission after a timeout
*/
private timeoutPermission(key: string) {
const existingTimeout = this.timeouts.get(key);
if (existingTimeout) {
clearTimeout(existingTimeout);
this.timeouts.delete(key);
private async retrievePermissions(auth: RepoAuth, remote: string) {
const gitUrl = RepoUtils.getUrl(remote);
const canWrite = await this.checkWritePermission(auth, gitUrl);
if (canWrite) {
return this.set(auth, remote, GitRemotePermission.Write);
}
this.timeouts.set(
key,
setTimeout(() => {
this.tokenPermissions.delete(key);
this.timeouts.delete(key);
}, TOKEN_INVALIDATE_TIMEOUT),
);
const canRead = await this.checkReadPermission(auth, gitUrl);
if (canRead) {
return this.set(auth, remote, GitRemotePermission.Read);
}
return this.set(auth, remote, GitRemotePermission.None);
}
private getMapKey(token: string, remote: string) {
return `${this.hashToken(token)}/${remote}`;
private async checkWritePermission(auth: RepoAuth, gitUrl: string): Promise<boolean> {
const response = await this.http.fetch(`${gitUrl}/${GitServices.Push}`, {
headers: this.getHeaders(auth),
});
return response.status === 200;
}
private hashToken(token: string): string {
return SecureUtils.sha512(token);
private async checkReadPermission(auth: RepoAuth, gitUrl: string): Promise<boolean> {
const response = await this.http.fetch(`${gitUrl}/${GitServices.Pull}`, {
headers: this.getHeaders(auth),
});
return response.status === 200;
}
private getHeaders(auth: RepoAuth): StringMap<string> {
const header = auth.toAuthorizationHeader();
if (!header) {
return {};
}
return {
Authorization: header,
};
}
}
enum GitServices {
Push = "git-receive-pack",
Pull = "git-upload-pack",
}

Просмотреть файл

@ -0,0 +1,5 @@
export enum GitRemotePermission {
None,
Read,
Write,
}

Просмотреть файл

@ -1,77 +1,40 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Clone, Fetch, FetchOptions, Repository } from "nodegit";
import { Repository } from "nodegit";
import path from "path";
import { RepoAuth } from "../../core";
import { FSService } from "../fs";
import { GitFetchService, repoCacheFolder } from "../git-fetch";
import { GitRemotePermission, PermissionService } from "../permission";
const repoCacheFolder = path.join("./tmp", "repos");
export function getRepoMainPath(remote: string) {
return path.join(repoCacheFolder, encodeURIComponent(remote));
}
const defaultFetchOptions: FetchOptions = {
downloadTags: 0,
prune: Fetch.PRUNE.GIT_FETCH_PRUNE,
};
export interface GitBaseOptions {
auth?: RepoAuth;
}
@Injectable()
export class RepoService {
private cacheReady: Promise<string>;
constructor(private fs: FSService) {
this.cacheReady = fs.makeDir(repoCacheFolder);
}
constructor(
private fs: FSService,
private fetchService: GitFetchService,
private permissionService: PermissionService,
) {}
public async get(remote: string, options: GitBaseOptions = {}): Promise<Repository> {
const permission = await this.permissionService.get(options.auth || new RepoAuth(), remote);
if (permission === GitRemotePermission.None) {
throw new NotFoundException(`Cannot find or missing permission to access '${remote}'`);
}
const repoPath = getRepoMainPath(remote);
if (await this.fs.exists(repoPath)) {
const repo = await Repository.open(repoPath);
await this.fetchAll(repo, options);
return repo;
return this.fetchService.fetch(remote, repo, options);
} else {
return this.clone(remote, repoPath, options);
}
}
public async fetchAll(repo: Repository, options: GitBaseOptions) {
try {
await repo.fetchAll({
...defaultFetchOptions,
callbacks: {
credentials: credentialsCallback(options),
},
});
} catch {
throw new NotFoundException();
}
}
public async clone(remote: string, repoPath: string, options: GitBaseOptions): Promise<Repository> {
await this.cacheReady;
try {
return await Clone.clone(`https://${remote}`, repoPath, {
fetchOpts: {
...defaultFetchOptions,
callbacks: {
credentials: credentialsCallback(options),
},
},
});
} catch {
throw new NotFoundException();
return this.fetchService.clone(remote, repoPath, options);
}
}
}
function credentialsCallback(options: GitBaseOptions) {
return () => {
if (options.auth) {
return options.auth.toCreds();
}
return undefined;
};
}

Просмотреть файл

@ -1 +1,2 @@
export * from "./secure-utils";
export * from "./repo-utils";

6
src/utils/repo-utils.ts Normal file
Просмотреть файл

@ -0,0 +1,6 @@
export const RepoUtils = {
getUrl: (remote: string) => {
const suffix = remote.endsWith(".git") ? "" : ".git";
return `https://${remote}${suffix}`;
},
};

Просмотреть файл

@ -52,7 +52,13 @@
"in": "path"
},
{
"name": "x-oauth-basic",
"name": "x-authorization",
"required": false,
"in": "header",
"type": "string"
},
{
"name": "x-github-token",
"required": false,
"in": "header",
"type": "string"
@ -68,6 +74,9 @@
}
}
},
"400": {
"description": "When the api request header is malformed"
},
"404": {
"description": ""
}

Просмотреть файл

@ -44,6 +44,7 @@
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */
},
"files": ["src/definitions.d.ts"],
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "tmp", "bin"]
}