Skip to content

Commit 9353fc3

Browse files
committed
feat: gitlab-11.2-group-api-improvements
- use the new gitlab 11.2 `min_access_level` parameter in the groups api: publish rights are now configurable based on the access level of the user to the groups, it doesn't require owner permissions - add new configuration parameters for `access` and `publish` access levels
1 parent 2b45a0b commit 9353fc3

File tree

6 files changed

+160
-43
lines changed

6 files changed

+160
-43
lines changed

README.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@ the following:
1313

1414
- no admin token required
1515
- user authenticates with Personal Access Token
16-
- owned groups (no subgroups) are added to the user
17-
- publish packages if package scope or name is an owned group name
16+
- access & publish packages if package scope or name match a gitlab
17+
group for which the user has appropriate permissions
1818

1919
> This is experimental!
2020
21+
## Gitlab Version Compatibility
22+
23+
Gitlab 11.2+ is required due to usage of the `v4` api version and
24+
the granular group api `min_access_level` query param
25+
2126
## Use it
2227

2328
You need at least node version 8.x.x, codename **carbon**.
@@ -47,6 +52,8 @@ auth:
4752
authCache:
4853
enabled: true
4954
ttl: 300
55+
access: $reporter
56+
publish: $maintainer
5057

5158
uplinks:
5259
npmjs:

conf/docker.yaml

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ auth:
1111
authCache:
1212
enabled: true
1313
ttl: 300
14+
access: $reporter
15+
publish: $maintainer
1416

1517
uplinks:
1618
npmjs:
@@ -31,5 +33,5 @@ packages:
3133
gitlab: true
3234

3335
logs:
34-
- {type: stdout, format: pretty, level: info }
36+
- { type: stdout, format: pretty, level: info }
3537
#- {type: file, path: verdaccio.log, level: info}

conf/localhost.yaml

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
storage: /tmp/storage/data
2+
3+
plugins: /tmp/plugins
4+
5+
listen:
6+
- 0.0.0.0:4873
7+
8+
auth:
9+
gitlab:
10+
url: http://localhost:50080
11+
authCache:
12+
enabled: true
13+
ttl: 300
14+
access: $reporter
15+
publish: $maintainer
16+
17+
uplinks:
18+
npmjs:
19+
url: https://registry.npmjs.org/
20+
21+
packages:
22+
'@*/*':
23+
# scoped packages
24+
access: $all
25+
publish: $authenticated
26+
proxy: npmjs
27+
gitlab: true
28+
29+
'**':
30+
access: $all
31+
publish: $authenticated
32+
proxy: npmjs
33+
gitlab: true
34+
35+
logs:
36+
- { type: stdout, format: pretty, level: trace }
37+
#- {type: file, path: verdaccio.log, level: info}

docker-compose.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ version: '3'
22

33
services:
44
gitlab:
5-
image: 'gitlab/gitlab-ce:latest'
5+
image: 'gitlab/gitlab-ce:nightly'
66
restart: always
77
environment:
88
- GITLAB_ROOT_PASSWORD=verdaccio

src/authcache.js

+10-5
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class AuthCache {
2424
});
2525
this.storage.on('expired', (key, value) => {
2626
if (this.logger.trace()) {
27-
this.logger.trace(`[gitlab] expired key: ${key} with value: ${value}`);
27+
this.logger.trace(`[gitlab] expired key: ${key} with value:`, value);
2828
}
2929
});
3030
}
@@ -45,15 +45,20 @@ export class AuthCache {
4545

4646
}
4747

48+
export type UserDataGroups = {
49+
access: string[],
50+
publish: string[]
51+
};
52+
4853
export class UserData {
4954
_username: string;
50-
_groups: string[];
55+
_groups: UserDataGroups;
5156

5257
get username(): string { return this._username; }
53-
get groups(): string[] { return this._groups; }
54-
set groups(groups: string[]) { this._groups = groups; }
58+
get groups(): UserDataGroups { return this._groups; }
59+
set groups(groups: UserDataGroups) { this._groups = groups; }
5560

56-
constructor(username: string, groups: string[]) {
61+
constructor(username: string, groups: UserDataGroups) {
5762
this._username = username;
5863
this._groups = groups;
5964
}

src/gitlab.js

+100-34
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,50 @@
22
// SPDX-License-Identifier: MIT
33
// @flow
44

5+
import type { Callback, IPluginAuth, Logger, PluginOptions, RemoteUser, PackageAccess } from '@verdaccio/types';
6+
import type { UserDataGroups } from './authcache';
7+
58
import Gitlab from 'gitlab';
69
import { AuthCache, UserData } from './authcache';
710
import httperror from 'http-errors';
8-
import type { Callback, Config, IPluginAuth, Logger, PluginOptions, RemoteUser, PackageAccess } from '@verdaccio/types';
911

10-
export type VerdaccioGitlabConfig = Config & {
12+
export type VerdaccioGitlabAccessLevel =
13+
'$guest' |
14+
'$reporter' |
15+
'$developer' |
16+
'$maintainer' |
17+
'$owner';
18+
19+
export type VerdaccioGitlabConfig = {
1120
url: string,
1221
authCache?: {
1322
enabled?: boolean,
1423
ttl?: number
15-
}
24+
},
25+
access?: VerdaccioGitlabAccessLevel,
26+
publish?: VerdaccioGitlabAccessLevel
1627
};
1728

18-
type VerdaccioGitlabPackageAccess = PackageAccess & {
29+
export type VerdaccioGitlabPackageAccess = PackageAccess & {
1930
name: string,
2031
gitlab?: boolean
2132
}
2233

34+
const ACCESS_LEVEL_MAPPING = {
35+
$guest: 10,
36+
$reporter: 20,
37+
$developer: 30,
38+
$maintainer: 40,
39+
$owner: 50
40+
};
41+
2342
export default class VerdaccioGitLab implements IPluginAuth {
2443
options: PluginOptions;
2544
config: VerdaccioGitlabConfig;
2645
authCache: AuthCache;
2746
logger: Logger;
47+
accessLevel: VerdaccioGitlabAccessLevel;
48+
publishLevel: VerdaccioGitlabAccessLevel;
2849

2950
constructor(
3051
config: VerdaccioGitlabConfig,
@@ -46,6 +67,17 @@ export default class VerdaccioGitLab implements IPluginAuth {
4667
this.logger.info(`[gitlab] initialized auth cache with ttl: ${ttl} seconds`);
4768
}
4869

70+
this.accessLevel = '$reporter';
71+
if (this.config.access) {
72+
this.accessLevel = this.config.access;
73+
}
74+
this.logger.info(`[gitlab] access control level: ${this.config.access || ''}`);
75+
76+
this.publishLevel = '$owner';
77+
if (this.config.publish) {
78+
this.publishLevel = this.config.publish;
79+
}
80+
this.logger.info(`[gitlab] publish control level: ${this.config.publish || ''}`);
4981
}
5082

5183
authenticate(user: string, password: string, cb: Callback) {
@@ -55,8 +87,8 @@ export default class VerdaccioGitLab implements IPluginAuth {
5587
const cachedUserGroups = this._getCachedUserGroups(user, password);
5688

5789
if (cachedUserGroups) {
58-
this.logger.debug(`[gitlab] user: ${user} found in cache, authenticated with groups: ${cachedUserGroups.toString()}`);
59-
return cb(null, cachedUserGroups);
90+
this.logger.debug(`[gitlab] user: ${user} found in cache, authenticated with groups:`, cachedUserGroups);
91+
return cb(null, cachedUserGroups.publish);
6092
}
6193

6294
// Not found in cache, query gitlab
@@ -67,28 +99,47 @@ export default class VerdaccioGitLab implements IPluginAuth {
6799
token: password
68100
});
69101

70-
GitlabAPI.Users.current().then(response => {
102+
const pUsers = GitlabAPI.Users.current();
103+
return pUsers.then(response => {
71104
if (user !== response.username) {
72105
return cb(httperror[401]('wrong gitlab username'));
73106
}
74107

75-
// Set the groups of an authenticated user to themselves and all gitlab projects of which they are an owner
76-
let ownedGroups = [user];
77-
GitlabAPI.Groups.all({owned: true}).then(groups => {
78-
for (let group of groups) {
79-
if (group.path === group.full_path) {
80-
ownedGroups.push(group.path);
81-
}
82-
}
108+
const accessLevelId = this.config.access ? ACCESS_LEVEL_MAPPING[this.config.access] : null;
109+
const publishLevelId = this.config.publish ? ACCESS_LEVEL_MAPPING[this.config.publish] : null;
110+
111+
const userGroups = {
112+
access: [user],
113+
publish: [user]
114+
};
115+
116+
// Set the groups of an authenticated user:
117+
// - for access, themselves and all groups with access level $auth.gitlab.access configuration
118+
// - for publish, themselves and all groups with access level $auth.gitlab.publish configuration
119+
120+
const pAccessGroups = GitlabAPI.Groups.all({min_access_level: accessLevelId}).then(groups => {
121+
this._addGroupsToArray(groups, userGroups.access);
122+
}).catch(error => {
123+
this.logger.error(`[gitlab] user: ${user} error querying access groups: ${error}`);
124+
});
125+
126+
const pPublishGroups = GitlabAPI.Groups.all({min_access_level: publishLevelId}).then(groups => {
127+
this._addGroupsToArray(groups, userGroups.publish);
128+
}).catch(error => {
129+
this.logger.error(`[gitlab] user: ${user} error querying publish groups: ${error}`);
130+
});
83131

84-
// Store found groups in cache
85-
this._setCachedUserGroups(user, password, ownedGroups);
86-
this.logger.trace(`[gitlab] saving data in cache for user: ${user}`);
132+
const pGroups = Promise.all([pAccessGroups, pPublishGroups]);
133+
return pGroups.then(() => {
134+
this._setCachedUserGroups(user, password, userGroups);
87135

88136
this.logger.info(`[gitlab] user: ${user} authenticated`);
89-
this.logger.debug(`[gitlab] user: ${user} authenticated, with groups: ${ownedGroups.toString()}`);
90-
return cb(null, ownedGroups);
137+
this.logger.debug(`[gitlab] user: ${user} authenticated, with groups:`, userGroups);
138+
return cb(null, userGroups.publish);
139+
}).catch(error => {
140+
this.logger.error(`[gitlab] error authenticating: ${error}`);
91141
});
142+
92143
}).catch(error => {
93144
this.logger.info(`[gitlab] user: ${user} error authenticating: ${error.message || {}}`);
94145
if (error) {
@@ -104,68 +155,83 @@ export default class VerdaccioGitLab implements IPluginAuth {
104155

105156
allow_access(user: RemoteUser, _package: VerdaccioGitlabPackageAccess, cb: Callback) {
106157
if (!_package.gitlab) { return cb(); }
158+
107159
if ((_package.access || []).includes('$authenticated') && user.name !== undefined) {
108160
this.logger.debug(`[gitlab] allow user: ${user.name} access to package: ${_package.name}`);
109161
return cb(null, true);
110-
} else if (! (_package.access || []).includes('$authenticated')) {
162+
} else if (!(_package.access || []).includes('$authenticated')) {
111163
this.logger.debug(`[gitlab] allow unauthenticated access to package: ${_package.name}`);
112164
return cb(null, true);
113165
} else {
114166
this.logger.debug(`[gitlab] deny user: ${user.name || ''} access to package: ${_package.name}`);
115167
return cb(null, false);
116168
}
169+
117170
}
118171

119172
allow_publish(user: RemoteUser, _package: VerdaccioGitlabPackageAccess, cb: Callback) {
120173
if (!_package.gitlab) { return cb(); }
121-
let packageScopeOwner = false;
122-
let packageOwner = false;
174+
let packageScopePermit = false;
175+
let packagePermit = false;
123176

124177
// Only allow to publish packages when:
125178
// - the package has exactly the same name as one of the user groups, or
126179
// - the package scope is the same as one of the user groups
127180
for (let real_group of user.real_groups) { // jscs:ignore requireCamelCaseOrUpperCaseIdentifiers
128181
this.logger.trace(`[gitlab] publish: checking group: ${real_group} for user: ${user.name || ''} and package: ${_package.name}`);
129182
if (real_group === _package.name) {
130-
packageOwner = true;
183+
packagePermit = true;
131184
break;
132185
} else {
133186
if (_package.name.indexOf('@') === 0) {
134187
if (real_group === _package.name.slice(1, _package.name.lastIndexOf('/'))) {
135-
packageScopeOwner = true;
188+
packageScopePermit = true;
136189
break;
137190
}
138191
}
139192
}
140193
}
141194

142-
if (packageOwner === true) {
143-
this.logger.debug(`[gitlab] user: ${user.name || ''} allowed to publish package: ${_package.name} as owner of package-name`);
195+
if (packagePermit === true) {
196+
this.logger.debug(`[gitlab] user: ${user.name || ''} allowed to publish package: ${_package.name} based on package-name`);
144197
return cb(null, false);
145198
} else {
146-
if (packageScopeOwner === true) {
147-
this.logger.debug(`[gitlab] user: ${user.name || ''} allowed to publish package: ${_package.name} as owner of package-scope`);
199+
if (packageScopePermit === true) {
200+
this.logger.debug(`[gitlab] user: ${user.name || ''} allowed to publish package: ${_package.name} based on package-scope`);
148201
return cb(null, false);
149202
} else {
150203
this.logger.debug(`[gitlab] user: ${user.name || ''} denied from publishing package: ${_package.name}`);
151204
if (_package.name.indexOf('@') === 0) {
152-
return cb(httperror[403]('must be owner of package-scope'));
205+
return cb(httperror[403](`must have required permissions: ${this.config.publish || ''} at package-scope`));
153206
} else {
154-
return cb(httperror[403]('must be owner of package-name'));
207+
return cb(httperror[403](`must have required permissions: ${this.config.publish || ''} at package-name`));
155208
}
156209
}
157210
}
158211
}
159212

160-
_getCachedUserGroups(username: string, password: string): ?string[] {
213+
_getCachedUserGroups(username: string, password: string): ?UserDataGroups {
161214
if (! this.authCache) {
162215
return null;
163216
}
164217
const userData = this.authCache.findUser(username, password);
165218
return (userData || {}).groups || null;
166219
}
167220

168-
_setCachedUserGroups(username: string, password: string, groups: string[]): boolean {
169-
return this.authCache && this.authCache.storeUser(username, password, new UserData(username, groups));
221+
_setCachedUserGroups(username: string, password: string, groups: UserDataGroups): boolean {
222+
if (! this.authCache) {
223+
return false;
224+
}
225+
this.logger.trace(`[gitlab] saving data in cache for user: ${username}`);
226+
return this.authCache.storeUser(username, password, new UserData(username, groups));
227+
}
228+
229+
_addGroupsToArray(src: {path: string, full_path: string}[], dst: string[]) {
230+
src.forEach(group => {
231+
if (group.path === group.full_path) {
232+
dst.push(group.path);
233+
}
234+
});
170235
}
236+
171237
}

0 commit comments

Comments
 (0)