From fe24a0be919653d9e502f7729d9a804f2e28435d Mon Sep 17 00:00:00 2001 From: James Moger Date: Wed, 7 Dec 2011 19:33:10 -0500 Subject: [PATCH] Teams support. Teams simplify the management of user-repository access permissions. Teams have a list of restricted repositories. Users are also added to teams and that grants them access to those repositories. Federation and RPC support are still in-progress. --- docs/01_features.mkd | 1 + docs/01_setup.mkd | 179 ++-------- docs/04_releases.mkd | 1 + resources/users_16x16.png | Bin 0 -> 918 bytes src/com/gitblit/ConfigUserService.java | 307 ++++++++++++++++- src/com/gitblit/FileUserService.java | 323 +++++++++++++++++- src/com/gitblit/GitBlit.java | 81 ++++- src/com/gitblit/IUserService.java | 79 +++++ src/com/gitblit/models/TeamModel.java | 88 +++++ src/com/gitblit/models/UserModel.java | 25 +- src/com/gitblit/utils/DeepCopier.java | 135 ++++++++ .../gitblit/wicket/GitBlitWebApp.properties | 8 +- src/com/gitblit/wicket/WicketUtils.java | 8 + .../wicket/pages/EditRepositoryPage.html | 3 +- .../wicket/pages/EditRepositoryPage.java | 19 +- .../gitblit/wicket/pages/EditTeamPage.html | 24 ++ .../gitblit/wicket/pages/EditTeamPage.java | 169 +++++++++ .../gitblit/wicket/pages/EditUserPage.html | 5 +- .../gitblit/wicket/pages/EditUserPage.java | 26 +- src/com/gitblit/wicket/pages/UsersPage.html | 2 + src/com/gitblit/wicket/pages/UsersPage.java | 3 + src/com/gitblit/wicket/panels/TeamsPanel.html | 44 +++ src/com/gitblit/wicket/panels/TeamsPanel.java | 89 +++++ src/com/gitblit/wicket/panels/UsersPanel.html | 2 +- tests/com/gitblit/tests/UserServiceTest.java | 107 ++++++ 25 files changed, 1547 insertions(+), 181 deletions(-) create mode 100644 resources/users_16x16.png create mode 100644 src/com/gitblit/models/TeamModel.java create mode 100644 src/com/gitblit/utils/DeepCopier.java create mode 100644 src/com/gitblit/wicket/pages/EditTeamPage.html create mode 100644 src/com/gitblit/wicket/pages/EditTeamPage.java create mode 100644 src/com/gitblit/wicket/panels/TeamsPanel.html create mode 100644 src/com/gitblit/wicket/panels/TeamsPanel.java diff --git a/docs/01_features.mkd b/docs/01_features.mkd index b43ea46f..4172f4e1 100644 --- a/docs/01_features.mkd +++ b/docs/01_features.mkd @@ -13,6 +13,7 @@ - Gitweb inspired web UI - Administrators may create, edit, rename, or delete repositories through the web UI or RPC interface - Administrators may create, edit, rename, or delete users through the web UI or RPC interface +- Administrators may create, edit, rename, or delete teams through the web UI or RPC interface - Repository Owners may edit repositories through the web UI - Git-notes display support - Branch metrics (uses Google Charts) diff --git a/docs/01_setup.mkd b/docs/01_setup.mkd index 468421d3..3256d1b3 100644 --- a/docs/01_setup.mkd +++ b/docs/01_setup.mkd @@ -157,8 +157,13 @@ All repositories created with Gitblit are *bare* and will automatically have *.g #### Repository Owner The *Repository Owner* has the special permission of being able to edit a repository through the web UI. The Repository Owner is not permitted to rename the repository, delete the repository, or reassign ownership to another user. -### Administering Users (Gitblit v0.8.0+) -All users are stored in the `users.conf` file or in the file you specified in `gitblit.properties`.
+### Teams + +Since v0.8.0, Gitblit supports *teams* for the original `users.properties` user service and the current default user service `users.conf`. Teams have assigned users and assigned repositories. A user can be a member of multiple teams and a repository may belong to multiple teams. This allows the administrator to quickly add a user to a team without having to keep track of all the appropriate repositories. + +### Administering Users (users.conf, Gitblit v0.8.0+) +All users are stored in the `users.conf` file or in the file you specified in `gitblit.properties`. Your file extension must be *.conf* in order to use this user service. + The `users.conf` file uses a Git-style configuration format: [user "admin"] @@ -167,14 +172,35 @@ The `users.conf` file uses a Git-style configuration format: role = "#notfederated" repository = repo1.git repository = repo2.git + + [user "hannibal"] + password = bossman + + [user "faceman"] + password = vanity + + [user "murdock"] + password = crazy + + [user "babaracus"] + password = grrrr + + [team "ateam"] + user = hannibal + user = faceman + user = murdock + user = babaracus + repository = topsecret.git The `users.conf` file allows flexibility for adding new fields to a UserModel object that the original `users.properties` file does not afford without imposing the complexity of relying on an embedded SQL database. -### Administering Users (Gitblit v0.5.0 - v0.7.0) -All users are stored in the `users.properties` file or in the file you specified in `gitblit.properties`.
+### Administering Users (users.properties, Gitblit v0.5.0 - v0.7.0) +All users are stored in the `users.properties` file or in the file you specified in `gitblit.properties`. Your file extension must be *.properties* in order to use this user service. + The format of `users.properties` follows Jetty's convention for HashRealms: username,password,role1,role2,role3... + @teamname,!username1,!username2,!username3,repository1,repository2,repository3... #### Usernames Usernames must be unique and are case-insensitive. @@ -191,149 +217,8 @@ Instead of maintaining a `users.conf` or `users.properties` file, you may want t You may use your own custom *com.gitblit.IUserService* implementation by specifying its fully qualified classname in the *realm.userService* setting. -Your user service class must be on Gitblit's classpath and must have a public default constructor. - -%BEGINCODE% -public interface IUserService { - - /** - * Setup the user service. - * - * @param settings - * @since 0.7.0 - */ - @Override - public void setup(IStoredSettings settings) { - } - - /** - * Does the user service support cookie authentication? - * - * @return true or false - */ - boolean supportsCookies(); - - /** - * Returns the cookie value for the specified user. - * - * @param model - * @return cookie value - */ - char[] getCookie(UserModel model); - - /** - * Authenticate a user based on their cookie. - * - * @param cookie - * @return a user object or null - */ - UserModel authenticate(char[] cookie); - - /** - * Authenticate a user based on a username and password. - * - * @param username - * @param password - * @return a user object or null - */ - UserModel authenticate(String username, char[] password); - - /** - * Retrieve the user object for the specified username. - * - * @param username - * @return a user object or null - */ - UserModel getUserModel(String username); - - /** - * Updates/writes a complete user object. - * - * @param model - * @return true if update is successful - */ - boolean updateUserModel(UserModel model); - - /** - * Adds/updates a user object keyed by username. This method allows for - * renaming a user. - * - * @param username - * the old username - * @param model - * the user object to use for username - * @return true if update is successful - */ - boolean updateUserModel(String username, UserModel model); - - /** - * Deletes the user object from the user service. - * - * @param model - * @return true if successful - */ - boolean deleteUserModel(UserModel model); - - /** - * Delete the user object with the specified username - * - * @param username - * @return true if successful - */ - boolean deleteUser(String username); - - /** - * Returns the list of all users available to the login service. - * - * @return list of all usernames - */ - List getAllUsernames(); - - /** - * Returns the list of all users who are allowed to bypass the access - * restriction placed on the specified repository. - * - * @param role - * the repository name - * @return list of all usernames that can bypass the access restriction - */ - List getUsernamesForRepositoryRole(String role); - - /** - * Sets the list of all uses who are allowed to bypass the access - * restriction placed on the specified repository. - * - * @param role - * the repository name - * @param usernames - * @return true if successful - */ - boolean setUsernamesForRepositoryRole(String role, List usernames); - - /** - * Renames a repository role. - * - * @param oldRole - * @param newRole - * @return true if successful - */ - boolean renameRepositoryRole(String oldRole, String newRole); - - /** - * Removes a repository role from all users. - * - * @param role - * @return true if successful - */ - boolean deleteRepositoryRole(String role); - - /** - * @See java.lang.Object.toString(); - * @return string representation of the login service - */ - String toString(); -} -%ENDCODE% +Your user service class must be on Gitblit's classpath and must have a public default constructor. +Please see the following interface definition [com.gitblit.IUserService](https://github.com/gitblit/gitblit/blob/master/src/com/gitblit/IUserService.java). ## Client Setup and Configuration ### Https with Self-Signed Certificates diff --git a/docs/04_releases.mkd b/docs/04_releases.mkd index 7a0cb094..343f794c 100644 --- a/docs/04_releases.mkd +++ b/docs/04_releases.mkd @@ -6,6 +6,7 @@ - added: new default user service implementation: com.gitblit.ConfigUserService (users.conf) This user service implementation allows for serialization and deserialization of more sophisticated Gitblit User objects and will open the door for more advanced Gitblit features. For upgrading installations, a `users.conf` file will automatically be created for you from your existing `users.properties` file on your first launch of Gitblit. You will have to manually set *realm.userService=users.conf* to switch to the new user service. The original `users.properties` file and it's corresponding implementation are deprecated. **New:** *realm.userService = users.conf* +- added: Teams for specifying user-repository access - added: Gitblit Express bundle to get started running Gitblit on RedHat's OpenShift cloud - added: optional Gravatar integration **New:** *web.allowGravatar = true* diff --git a/resources/users_16x16.png b/resources/users_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..247af645eae8c637c76cc11e7b56dd23fca84849 GIT binary patch literal 918 zcmV;H18Mw;P)Ngx4c;EIXpy)|!6NnY)Q5sCeJHe2D-=W@BEFQi6binppbrY7FXF30eGqlM zv}<;2$Yo8kvuXA+yED5pbI$3RjRgz-@IRdM|KIl={{K7V%>F+3Ap{T%tFRg#h&*$u zf^rX>iw&Ai3PXvI(rgJBC5vfPd_)MD_HioS2`*VU=RQG9P*Ih%a0OnC?;KFV(FAHH zWEPc*v7Dz0?O^GDU?D`3dEDKQjEeq5q{11u<((b~AmB0$3{g0|7iypf4uir31($G@ z5z=`+<2*mUBd&ydhES^uRLwW~@{&=|yB%!ak%k_OVb|aqjAypv>^DDwQVK#SsNO~4 zXFwy0>q#XMSyj@aXghqkVLG|7iG5f*Jb=1sqSkq9mkc8E-bs_`qiGng~cVjod3I=J{d1%^)i%}0{Zle3MM8-@s}Gr zKL6wy%t{T7s#Gc#vFXcHaAf1|f3}RI2L-cX)hien+8Q6*md^2(lNBC|lFV-fFeM1a zye%jDZr)Fle7v|&!b2m9u@f0=S~rhm>{AScM$xo@*TzFw+GCJ*7jECY#3`5)VQn*; z8I9ypo0DSON!FJomOvQn#bYbs@hj9A(8brh)c%mB(TW(+JM$wp&rXx_pnt(0scKewH zO#vo9)*u`U*Z$O`GwaB#S5d7;AXg6NGW3Rr<^Bm~XA6=*8fj_SM8RmIT(%+W`26as zGgS#tC1G9($3h)P;%(jk5WbRMvzj)3 zJ)hqh9|{J`i<0fF`(?gXo6`?Y-D+AtOW&(P;D15s<<-%jr4 s@7Dd69+?|gHQyT@C7HGN75^1r05bKFN3!=bYybcN07*qoM6N<$g8o9W9smFU literal 0 HcmV?d00001 diff --git a/src/com/gitblit/ConfigUserService.java b/src/com/gitblit/ConfigUserService.java index 28a16c50..a0a38e6a 100644 --- a/src/com/gitblit/ConfigUserService.java +++ b/src/com/gitblit/ConfigUserService.java @@ -32,7 +32,9 @@ import org.eclipse.jgit.util.FS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; +import com.gitblit.utils.DeepCopier; import com.gitblit.utils.StringUtils; /** @@ -51,6 +53,16 @@ import com.gitblit.utils.StringUtils; */ public class ConfigUserService implements IUserService { + private static final String TEAM = "team"; + + private static final String USER = "user"; + + private static final String PASSWORD = "password"; + + private static final String REPOSITORY = "repository"; + + private static final String ROLE = "role"; + private final File realmFile; private final Logger logger = LoggerFactory.getLogger(ConfigUserService.class); @@ -59,13 +71,7 @@ public class ConfigUserService implements IUserService { private final Map cookies = new ConcurrentHashMap(); - private final String userSection = "user"; - - private final String passwordField = "password"; - - private final String repositoryField = "repository"; - - private final String roleField = "role"; + private final Map teams = new ConcurrentHashMap(); private volatile long lastModified; @@ -77,7 +83,7 @@ public class ConfigUserService implements IUserService { * Setup the user service. * * @param settings - * @since 0.6.1 + * @since 0.7.0 */ @Override public void setup(IStoredSettings settings) { @@ -172,6 +178,11 @@ public class ConfigUserService implements IUserService { public UserModel getUserModel(String username) { read(); UserModel model = users.get(username.toLowerCase()); + if (model != null) { + // clone the model, otherwise all changes to this object are + // live and unpersisted + model = DeepCopier.copy(model); + } return model; } @@ -200,8 +211,34 @@ public class ConfigUserService implements IUserService { public boolean updateUserModel(String username, UserModel model) { try { read(); - users.remove(username.toLowerCase()); + UserModel oldUser = users.remove(username.toLowerCase()); users.put(model.username.toLowerCase(), model); + // null check on "final" teams because JSON-sourced UserModel + // can have a null teams object + if (model.teams != null) { + for (TeamModel team : model.teams) { + TeamModel t = teams.get(team.name.toLowerCase()); + if (t == null) { + // new team + team.addUser(username); + teams.put(team.name.toLowerCase(), team); + } else { + // do not clobber existing team definition + // maybe because this is a federated user + t.removeUser(username); + t.addUser(model.username); + } + } + + // check for implicit team removal + if (oldUser != null) { + for (TeamModel team : oldUser.teams) { + if (!model.isTeamMember(team.name)) { + team.removeUser(username); + } + } + } + } write(); return true; } catch (Throwable t) { @@ -233,7 +270,19 @@ public class ConfigUserService implements IUserService { try { // Read realm file read(); - users.remove(username.toLowerCase()); + UserModel model = users.remove(username.toLowerCase()); + // remove user from team + for (TeamModel team : model.teams) { + TeamModel t = teams.get(team.name); + if (t == null) { + // new team + team.removeUser(username); + teams.put(team.name.toLowerCase(), team); + } else { + // existing team + t.removeUser(username); + } + } write(); return true; } catch (Throwable t) { @@ -242,6 +291,172 @@ public class ConfigUserService implements IUserService { return false; } + /** + * Returns the list of all teams available to the login service. + * + * @return list of all teams + * @since 0.8.0 + */ + @Override + public List getAllTeamNames() { + read(); + List list = new ArrayList(teams.keySet()); + return list; + } + + /** + * Returns the list of all users who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @param role + * the repository name + * @return list of all usernames that can bypass the access restriction + */ + @Override + public List getTeamnamesForRepositoryRole(String role) { + List list = new ArrayList(); + try { + read(); + for (Map.Entry entry : teams.entrySet()) { + TeamModel model = entry.getValue(); + if (model.hasRepository(role)) { + list.add(model.name); + } + } + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to get teamnames for role {0}!", role), t); + } + return list; + } + + /** + * Sets the list of all teams who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @param role + * the repository name + * @param teamnames + * @return true if successful + */ + @Override + public boolean setTeamnamesForRepositoryRole(String role, List teamnames) { + try { + Set specifiedTeams = new HashSet(); + for (String teamname : teamnames) { + specifiedTeams.add(teamname.toLowerCase()); + } + + read(); + + // identify teams which require add or remove role + for (TeamModel team : teams.values()) { + // team has role, check against revised team list + if (specifiedTeams.contains(team.name.toLowerCase())) { + team.addRepository(role); + } else { + // remove role from team + team.removeRepository(role); + } + } + + // persist changes + write(); + return true; + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to set teams for role {0}!", role), t); + } + return false; + } + + /** + * Retrieve the team object for the specified team name. + * + * @param teamname + * @return a team object or null + * @since 0.8.0 + */ + @Override + public TeamModel getTeamModel(String teamname) { + read(); + TeamModel model = teams.get(teamname.toLowerCase()); + if (model != null) { + // clone the model, otherwise all changes to this object are + // live and unpersisted + model = DeepCopier.copy(model); + } + return model; + } + + /** + * Updates/writes a complete team object. + * + * @param model + * @return true if update is successful + * @since 0.8.0 + */ + @Override + public boolean updateTeamModel(TeamModel model) { + return updateTeamModel(model.name, model); + } + + /** + * Updates/writes and replaces a complete team object keyed by teamname. + * This method allows for renaming a team. + * + * @param teamname + * the old teamname + * @param model + * the team object to use for teamname + * @return true if update is successful + * @since 0.8.0 + */ + @Override + public boolean updateTeamModel(String teamname, TeamModel model) { + try { + read(); + teams.remove(teamname.toLowerCase()); + teams.put(model.name.toLowerCase(), model); + write(); + return true; + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to update team model {0}!", model.name), t); + } + return false; + } + + /** + * Deletes the team object from the user service. + * + * @param model + * @return true if successful + * @since 0.8.0 + */ + @Override + public boolean deleteTeamModel(TeamModel model) { + return deleteTeam(model.name); + } + + /** + * Delete the team object with the specified teamname + * + * @param teamname + * @return true if successful + * @since 0.8.0 + */ + @Override + public boolean deleteTeam(String teamname) { + try { + // Read realm file + read(); + teams.remove(teamname.toLowerCase()); + write(); + return true; + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to delete team {0}!", teamname), t); + } + return false; + } + /** * Returns the list of all users available to the login service. * @@ -337,6 +552,13 @@ public class ConfigUserService implements IUserService { } } + // identify teams which require role rename + for (TeamModel model : teams.values()) { + if (model.hasRepository(oldRole)) { + model.removeRepository(oldRole); + model.addRepository(newRole); + } + } // persist changes write(); return true; @@ -363,6 +585,11 @@ public class ConfigUserService implements IUserService { user.removeRepository(role); } + // identify teams which require role rename + for (TeamModel team : teams.values()) { + team.removeRepository(role); + } + // persist changes write(); return true; @@ -383,8 +610,10 @@ public class ConfigUserService implements IUserService { File realmFileCopy = new File(realmFile.getAbsolutePath() + ".tmp"); StoredConfig config = new FileBasedConfig(realmFileCopy, FS.detect()); + + // write users for (UserModel model : users.values()) { - config.setString(userSection, model.username, passwordField, model.password); + config.setString(USER, model.username, PASSWORD, model.password); // user roles List roles = new ArrayList(); @@ -394,12 +623,33 @@ public class ConfigUserService implements IUserService { if (model.excludeFromFederation) { roles.add(Constants.NOT_FEDERATED_ROLE); } - config.setStringList(userSection, model.username, roleField, roles); + config.setStringList(USER, model.username, ROLE, roles); // repository memberships - config.setStringList(userSection, model.username, repositoryField, - new ArrayList(model.repositories)); + // null check on "final" repositories because JSON-sourced UserModel + // can have a null repositories object + if (model.repositories != null) { + config.setStringList(USER, model.username, REPOSITORY, new ArrayList( + model.repositories)); + } + } + + // write teams + for (TeamModel model : teams.values()) { + // null check on "final" repositories because JSON-sourced TeamModel + // can have a null repositories object + if (model.repositories != null) { + config.setStringList(TEAM, model.name, REPOSITORY, new ArrayList( + model.repositories)); + } + + // null check on "final" users because JSON-sourced TeamModel + // can have a null users object + if (model.users != null) { + config.setStringList(TEAM, model.name, USER, new ArrayList(model.users)); + } } + config.save(); // If the write is successful, delete the current file and rename @@ -429,23 +679,25 @@ public class ConfigUserService implements IUserService { lastModified = realmFile.lastModified(); users.clear(); cookies.clear(); + teams.clear(); + try { StoredConfig config = new FileBasedConfig(realmFile, FS.detect()); config.load(); - Set usernames = config.getSubsections(userSection); + Set usernames = config.getSubsections(USER); for (String username : usernames) { UserModel user = new UserModel(username); - user.password = config.getString(userSection, username, passwordField); + user.password = config.getString(USER, username, PASSWORD); // user roles Set roles = new HashSet(Arrays.asList(config.getStringList( - userSection, username, roleField))); + USER, username, ROLE))); user.canAdmin = roles.contains(Constants.ADMIN_ROLE); user.excludeFromFederation = roles.contains(Constants.NOT_FEDERATED_ROLE); // repository memberships Set repositories = new HashSet(Arrays.asList(config - .getStringList(userSection, username, repositoryField))); + .getStringList(USER, username, REPOSITORY))); for (String repository : repositories) { user.addRepository(repository); } @@ -454,6 +706,25 @@ public class ConfigUserService implements IUserService { users.put(username, user); cookies.put(StringUtils.getSHA1(username + user.password), user); } + + // load the teams + Set teamnames = config.getSubsections(TEAM); + for (String teamname : teamnames) { + TeamModel team = new TeamModel(teamname); + team.addRepositories(Arrays.asList(config.getStringList(TEAM, teamname, + REPOSITORY))); + team.addUsers(Arrays.asList(config.getStringList(TEAM, teamname, USER))); + + teams.put(team.name.toLowerCase(), team); + + // set the teams on the users + for (String user : team.users) { + UserModel model = users.get(user); + if (model != null) { + model.teams.add(team); + } + } + } } catch (Exception e) { logger.error(MessageFormat.format("Failed to read {0}", realmFile), e); } diff --git a/src/com/gitblit/FileUserService.java b/src/com/gitblit/FileUserService.java index a98e4175..880ca7b8 100644 --- a/src/com/gitblit/FileUserService.java +++ b/src/com/gitblit/FileUserService.java @@ -30,7 +30,9 @@ import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; +import com.gitblit.utils.DeepCopier; import com.gitblit.utils.StringUtils; /** @@ -53,6 +55,8 @@ public class FileUserService extends FileSettings implements IUserService { private final Map cookies = new ConcurrentHashMap(); + private final Map teams = new ConcurrentHashMap(); + public FileUserService(File realmFile) { super(realmFile.getAbsolutePath()); } @@ -61,7 +65,7 @@ public class FileUserService extends FileSettings implements IUserService { * Setup the user service. * * @param settings - * @since 0.6.1 + * @since 0.7.0 */ @Override public void setup(IStoredSettings settings) { @@ -181,6 +185,12 @@ public class FileUserService extends FileSettings implements IUserService { model.addRepository(role); } } + // set the teams for the user + for (TeamModel team : teams.values()) { + if (team.hasUser(username)) { + model.teams.add(DeepCopier.copy(team)); + } + } return model; } @@ -209,6 +219,7 @@ public class FileUserService extends FileSettings implements IUserService { public boolean updateUserModel(String username, UserModel model) { try { Properties allUsers = read(); + UserModel oldUser = getUserModel(username); ArrayList roles = new ArrayList(model.repositories); // Permissions @@ -231,6 +242,32 @@ public class FileUserService extends FileSettings implements IUserService { allUsers.remove(username); allUsers.put(model.username, sb.toString()); + // null check on "final" teams because JSON-sourced UserModel + // can have a null teams object + if (model.teams != null) { + // update team cache + for (TeamModel team : model.teams) { + TeamModel t = getTeamModel(team.name); + if (t == null) { + // new team + t = team; + } + t.removeUser(username); + t.addUser(model.username); + updateTeamCache(allUsers, t.name, t); + } + + // check for implicit team removal + if (oldUser != null) { + for (TeamModel team : oldUser.teams) { + if (!model.isTeamMember(team.name)) { + team.removeUser(username); + updateTeamCache(allUsers, team.name, team); + } + } + } + } + write(allUsers); return true; } catch (Throwable t) { @@ -262,7 +299,17 @@ public class FileUserService extends FileSettings implements IUserService { try { // Read realm file Properties allUsers = read(); + UserModel user = getUserModel(username); allUsers.remove(username); + for (TeamModel team : user.teams) { + TeamModel t = getTeamModel(team.name); + if (t == null) { + // new team + t = team; + } + t.removeUser(username); + updateTeamCache(allUsers, t.name, t); + } write(allUsers); return true; } catch (Throwable t) { @@ -279,7 +326,14 @@ public class FileUserService extends FileSettings implements IUserService { @Override public List getAllUsernames() { Properties allUsers = read(); - List list = new ArrayList(allUsers.stringPropertyNames()); + List list = new ArrayList(); + for (String user : allUsers.stringPropertyNames()) { + if (user.charAt(0) == '@') { + // skip team user definitions + continue; + } + list.add(user); + } return list; } @@ -297,6 +351,9 @@ public class FileUserService extends FileSettings implements IUserService { try { Properties allUsers = read(); for (String username : allUsers.stringPropertyNames()) { + if (username.charAt(0) == '@') { + continue; + } String value = allUsers.getProperty(username); String[] values = value.split(","); // skip first value (password) @@ -315,7 +372,7 @@ public class FileUserService extends FileSettings implements IUserService { } /** - * Sets the list of all uses who are allowed to bypass the access + * Sets the list of all users who are allowed to bypass the access * restriction placed on the specified repository. * * @param role @@ -426,7 +483,7 @@ public class FileUserService extends FileSettings implements IUserService { sb.append(','); sb.append(newRole); sb.append(','); - + // skip first value (password) for (int i = 1; i < values.length; i++) { String value = values[i]; @@ -520,7 +577,7 @@ public class FileUserService extends FileSettings implements IUserService { FileWriter writer = new FileWriter(realmFileCopy); properties .store(writer, - "# Gitblit realm file format: username=password,\\#permission,repository1,repository2..."); + " Gitblit realm file format:\n username=password,\\#permission,repository1,repository2...\n @teamname=!username1,!username2,!username3,repository1,repository2..."); writer.close(); // If the write is successful, delete the current file and rename // the temporary copy to the original filename. @@ -551,11 +608,31 @@ public class FileUserService extends FileSettings implements IUserService { if (lastRead != lastModified()) { // reload hash cache cookies.clear(); + teams.clear(); + for (String username : allUsers.stringPropertyNames()) { String value = allUsers.getProperty(username); String[] roles = value.split(","); - String password = roles[0]; - cookies.put(StringUtils.getSHA1(username + password), username); + if (username.charAt(0) == '@') { + // team definition + TeamModel team = new TeamModel(username.substring(1)); + List repositories = new ArrayList(); + List users = new ArrayList(); + for (String role : roles) { + if (role.charAt(0) == '!') { + users.add(role.substring(1)); + } else { + repositories.add(role); + } + } + team.addRepositories(repositories); + team.addUsers(users); + teams.put(team.name.toLowerCase(), team); + } else { + // user definition + String password = roles[0]; + cookies.put(StringUtils.getSHA1(username + password), username); + } } } return allUsers; @@ -565,4 +642,236 @@ public class FileUserService extends FileSettings implements IUserService { public String toString() { return getClass().getSimpleName() + "(" + propertiesFile.getAbsolutePath() + ")"; } + + /** + * Returns the list of all teams available to the login service. + * + * @return list of all teams + * @since 0.8.0 + */ + @Override + public List getAllTeamNames() { + List list = new ArrayList(teams.keySet()); + return list; + } + + /** + * Returns the list of all teams who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @param role + * the repository name + * @return list of all teamnames that can bypass the access restriction + */ + @Override + public List getTeamnamesForRepositoryRole(String role) { + List list = new ArrayList(); + try { + Properties allUsers = read(); + for (String team : allUsers.stringPropertyNames()) { + if (team.charAt(0) != '@') { + // skip users + continue; + } + String value = allUsers.getProperty(team); + String[] values = value.split(","); + for (int i = 0; i < values.length; i++) { + String r = values[i]; + if (r.equalsIgnoreCase(role)) { + // strip leading @ + list.add(team.substring(1)); + break; + } + } + } + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to get teamnames for role {0}!", role), t); + } + return list; + } + + /** + * Sets the list of all teams who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @param role + * the repository name + * @param teamnames + * @return true if successful + */ + @Override + public boolean setTeamnamesForRepositoryRole(String role, List teamnames) { + try { + Set specifiedTeams = new HashSet(teamnames); + Set needsAddRole = new HashSet(specifiedTeams); + Set needsRemoveRole = new HashSet(); + + // identify teams which require add and remove role + Properties allUsers = read(); + for (String team : allUsers.stringPropertyNames()) { + if (team.charAt(0) != '@') { + // skip users + continue; + } + String name = team.substring(1); + String value = allUsers.getProperty(team); + String[] values = value.split(","); + for (int i = 0; i < values.length; i++) { + String r = values[i]; + if (r.equalsIgnoreCase(role)) { + // team has role, check against revised team list + if (specifiedTeams.contains(name)) { + needsAddRole.remove(name); + } else { + // remove role from team + needsRemoveRole.add(name); + } + break; + } + } + } + + // add roles to teams + for (String name : needsAddRole) { + String team = "@" + name; + String teamValues = allUsers.getProperty(team); + teamValues += "," + role; + allUsers.put(team, teamValues); + } + + // remove role from team + for (String name : needsRemoveRole) { + String team = "@" + name; + String[] values = allUsers.getProperty(team).split(","); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + String value = values[i]; + if (!value.equalsIgnoreCase(role)) { + sb.append(value); + sb.append(','); + } + } + sb.setLength(sb.length() - 1); + + // update properties + allUsers.put(team, sb.toString()); + } + + // persist changes + write(allUsers); + return true; + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to set teamnames for role {0}!", role), t); + } + return false; + } + + /** + * Retrieve the team object for the specified team name. + * + * @param teamname + * @return a team object or null + * @since 0.8.0 + */ + @Override + public TeamModel getTeamModel(String teamname) { + read(); + TeamModel team = teams.get(teamname.toLowerCase()); + if (team != null) { + // clone the model, otherwise all changes to this object are + // live and unpersisted + team = DeepCopier.copy(team); + } + return team; + } + + /** + * Updates/writes a complete team object. + * + * @param model + * @return true if update is successful + * @since 0.8.0 + */ + @Override + public boolean updateTeamModel(TeamModel model) { + return updateTeamModel(model.name, model); + } + + /** + * Updates/writes and replaces a complete team object keyed by teamname. + * This method allows for renaming a team. + * + * @param teamname + * the old teamname + * @param model + * the team object to use for teamname + * @return true if update is successful + * @since 0.8.0 + */ + @Override + public boolean updateTeamModel(String teamname, TeamModel model) { + try { + Properties allUsers = read(); + updateTeamCache(allUsers, teamname, model); + write(allUsers); + return true; + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to update team model {0}!", model.name), t); + } + return false; + } + + private void updateTeamCache(Properties allUsers, String teamname, TeamModel model) { + StringBuilder sb = new StringBuilder(); + for (String repository : model.repositories) { + sb.append(repository); + sb.append(','); + } + for (String user : model.users) { + sb.append('!'); + sb.append(user); + sb.append(','); + } + // trim trailing comma + sb.setLength(sb.length() - 1); + allUsers.remove("@" + teamname); + allUsers.put("@" + model.name, sb.toString()); + + // update team cache + teams.remove(teamname.toLowerCase()); + teams.put(model.name.toLowerCase(), model); + } + + /** + * Deletes the team object from the user service. + * + * @param model + * @return true if successful + * @since 0.8.0 + */ + @Override + public boolean deleteTeamModel(TeamModel model) { + return deleteTeam(model.name); + } + + /** + * Delete the team object with the specified teamname + * + * @param teamname + * @return true if successful + * @since 0.8.0 + */ + @Override + public boolean deleteTeam(String teamname) { + Properties allUsers = read(); + teams.remove(teamname.toLowerCase()); + allUsers.remove("@" + teamname); + try { + write(allUsers); + return true; + } catch (Throwable t) { + logger.error(MessageFormat.format("Failed to delete team {0}!", teamname), t); + } + return false; + } } diff --git a/src/com/gitblit/GitBlit.java b/src/com/gitblit/GitBlit.java index 60a96e66..13dc3fa5 100644 --- a/src/com/gitblit/GitBlit.java +++ b/src/com/gitblit/GitBlit.java @@ -69,6 +69,7 @@ import com.gitblit.models.RepositoryModel; import com.gitblit.models.ServerSettings; import com.gitblit.models.ServerStatus; import com.gitblit.models.SettingModel; +import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; import com.gitblit.utils.ByteFormat; import com.gitblit.utils.FederationUtils; @@ -510,6 +511,83 @@ public class GitBlit implements ServletContextListener { } } + /** + * Returns the list of available teams that a user or repository may be + * assigned to. + * + * @return the list of teams + */ + public List getAllTeamnames() { + List teams = new ArrayList(userService.getAllTeamNames()); + Collections.sort(teams); + return teams; + } + + /** + * Returns the TeamModel object for the specified name. + * + * @param teamname + * @return a TeamModel object or null + */ + public TeamModel getTeamModel(String teamname) { + return userService.getTeamModel(teamname); + } + + /** + * Returns the list of all teams who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @see IUserService.getTeamnamesForRepositoryRole(String) + * @param repository + * @return list of all teamnames that can bypass the access restriction + */ + public List getRepositoryTeams(RepositoryModel repository) { + return userService.getTeamnamesForRepositoryRole(repository.name); + } + + /** + * Sets the list of all uses who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @see IUserService.setTeamnamesForRepositoryRole(String, List) + * @param repository + * @param teamnames + * @return true if successful + */ + public boolean setRepositoryTeams(RepositoryModel repository, List repositoryTeams) { + return userService.setTeamnamesForRepositoryRole(repository.name, repositoryTeams); + } + /** + * Updates the TeamModel object for the specified name. + * + * @param teamname + * @param team + * @param isCreate + */ + public void updateTeamModel(String teamname, TeamModel team, boolean isCreate) throws GitBlitException { + if (!teamname.equalsIgnoreCase(team.name)) { + if (userService.getTeamModel(team.name) != null) { + throw new GitBlitException(MessageFormat.format( + "Failed to rename ''{0}'' because ''{1}'' already exists.", teamname, + team.name)); + } + } + if (!userService.updateTeamModel(teamname, team)) { + throw new GitBlitException(isCreate ? "Failed to add team!" : "Failed to update team!"); + } + } + + /** + * Delete the team object with the specified teamname + * + * @see IUserService.deleteTeam(String) + * @param teamname + * @return true if successful + */ + public boolean deleteTeam(String teamname) { + return userService.deleteTeam(teamname); + } + /** * Clears all the cached data for the specified repository. * @@ -1115,6 +1193,7 @@ public class GitBlit implements ServletContextListener { case PULL_REPOSITORIES: return token.equals(all) || token.equals(unr) || token.equals(jur); case PULL_USERS: + case PULL_TEAMS: return token.equals(all) || token.equals(unr); case PULL_SETTINGS: return token.equals(all); @@ -1473,7 +1552,7 @@ public class GitBlit implements ServletContextListener { configService.updateUserModel(userModel); } } - + // issue suggestion about switching to users.conf logger.warn("Please consider using \"users.conf\" instead of the deprecated \"users.properties\" file"); } else if (realmFile.getName().toLowerCase().endsWith(".conf")) { diff --git a/src/com/gitblit/IUserService.java b/src/com/gitblit/IUserService.java index e143c794..98dbf0d6 100644 --- a/src/com/gitblit/IUserService.java +++ b/src/com/gitblit/IUserService.java @@ -17,6 +17,7 @@ package com.gitblit; import java.util.List; +import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; /** @@ -121,6 +122,84 @@ public interface IUserService { */ List getAllUsernames(); + /** + * Returns the list of all teams available to the login service. + * + * @return list of all teams + * @since 0.8.0 + */ + List getAllTeamNames(); + + /** + * Returns the list of all users who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @param role + * the repository name + * @return list of all usernames that can bypass the access restriction + */ + List getTeamnamesForRepositoryRole(String role); + + /** + * Sets the list of all teams who are allowed to bypass the access + * restriction placed on the specified repository. + * + * @param role + * the repository name + * @param teamnames + * @return true if successful + */ + boolean setTeamnamesForRepositoryRole(String role, List teamnames); + + /** + * Retrieve the team object for the specified team name. + * + * @param teamname + * @return a team object or null + * @since 0.8.0 + */ + TeamModel getTeamModel(String teamname); + + /** + * Updates/writes a complete team object. + * + * @param model + * @return true if update is successful + * @since 0.8.0 + */ + boolean updateTeamModel(TeamModel model); + + /** + * Updates/writes and replaces a complete team object keyed by teamname. + * This method allows for renaming a team. + * + * @param teamname + * the old teamname + * @param model + * the team object to use for teamname + * @return true if update is successful + * @since 0.8.0 + */ + boolean updateTeamModel(String teamname, TeamModel model); + + /** + * Deletes the team object from the user service. + * + * @param model + * @return true if successful + * @since 0.8.0 + */ + boolean deleteTeamModel(TeamModel model); + + /** + * Delete the team object with the specified teamname + * + * @param teamname + * @return true if successful + * @since 0.8.0 + */ + boolean deleteTeam(String teamname); + /** * Returns the list of all users who are allowed to bypass the access * restriction placed on the specified repository. diff --git a/src/com/gitblit/models/TeamModel.java b/src/com/gitblit/models/TeamModel.java new file mode 100644 index 00000000..195b9d5c --- /dev/null +++ b/src/com/gitblit/models/TeamModel.java @@ -0,0 +1,88 @@ +/* + * Copyright 2011 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.models; + +import java.io.Serializable; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * TeamModel is a serializable model class that represents a group of users and + * a list of accessible repositories. + * + * @author James Moger + * + */ +public class TeamModel implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + // field names are reflectively mapped in EditTeam page + public String name; + public final Set users = new HashSet(); + public final Set repositories = new HashSet(); + + public TeamModel(String name) { + this.name = name; + } + + public boolean hasRepository(String name) { + return repositories.contains(name.toLowerCase()); + } + + public void addRepository(String name) { + repositories.add(name.toLowerCase()); + } + + public void addRepositories(Collection names) { + for (String name:names) { + repositories.add(name.toLowerCase()); + } + } + + public void removeRepository(String name) { + repositories.remove(name.toLowerCase()); + } + + public boolean hasUser(String name) { + return users.contains(name.toLowerCase()); + } + + public void addUser(String name) { + users.add(name.toLowerCase()); + } + + public void addUsers(Collection names) { + for (String name:names) { + users.add(name.toLowerCase()); + } + } + + public void removeUser(String name) { + users.remove(name.toLowerCase()); + } + + @Override + public String toString() { + return name; + } + + @Override + public int compareTo(TeamModel o) { + return name.compareTo(o.name); + } +} diff --git a/src/com/gitblit/models/UserModel.java b/src/com/gitblit/models/UserModel.java index 8c99512b..bd8974d7 100644 --- a/src/com/gitblit/models/UserModel.java +++ b/src/com/gitblit/models/UserModel.java @@ -40,6 +40,7 @@ public class UserModel implements Principal, Serializable, Comparable public boolean canAdmin; public boolean excludeFromFederation; public final Set repositories = new HashSet(); + public final Set teams = new HashSet(); public UserModel(String username) { this.username = username; @@ -54,13 +55,24 @@ public class UserModel implements Principal, Serializable, Comparable */ @Deprecated public boolean canAccessRepository(String repositoryName) { - return canAdmin || repositories.contains(repositoryName.toLowerCase()); + return canAdmin || repositories.contains(repositoryName.toLowerCase()) + || hasTeamAccess(repositoryName); } public boolean canAccessRepository(RepositoryModel repository) { boolean isOwner = !StringUtils.isEmpty(repository.owner) && repository.owner.equals(username); - return canAdmin || isOwner || repositories.contains(repository.name.toLowerCase()); + return canAdmin || isOwner || repositories.contains(repository.name.toLowerCase()) + || hasTeamAccess(repository.name); + } + + public boolean hasTeamAccess(String repositoryName) { + for (TeamModel team : teams) { + if (team.hasRepository(repositoryName)) { + return true; + } + } + return false; } public boolean hasRepository(String name) { @@ -75,6 +87,15 @@ public class UserModel implements Principal, Serializable, Comparable repositories.remove(name.toLowerCase()); } + public boolean isTeamMember(String teamname) { + for (TeamModel team : teams) { + if (team.name.equalsIgnoreCase(teamname)) { + return true; + } + } + return false; + } + @Override public String getName() { return username; diff --git a/src/com/gitblit/utils/DeepCopier.java b/src/com/gitblit/utils/DeepCopier.java new file mode 100644 index 00000000..5df30623 --- /dev/null +++ b/src/com/gitblit/utils/DeepCopier.java @@ -0,0 +1,135 @@ +/* + * Copyright 2011 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.utils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; + +public class DeepCopier { + + /** + * Produce a deep copy of the given object. Serializes the entire object to + * a byte array in memory. Recommended for relatively small objects. + */ + @SuppressWarnings("unchecked") + public static T copy(T original) { + T o = null; + try { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(byteOut); + oos.writeObject(original); + ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(byteIn); + try { + o = (T) ois.readObject(); + } catch (ClassNotFoundException cex) { + // actually can not happen in this instance + } + } catch (IOException iox) { + // doesn't seem likely to happen as these streams are in memory + throw new RuntimeException(iox); + } + return o; + } + + /** + * This conserves heap memory!!!!! Produce a deep copy of the given object. + * Serializes the object through a pipe between two threads. Recommended for + * very large objects. The current thread is used for serializing the + * original object in order to respect any synchronization the caller may + * have around it, and a new thread is used for deserializing the copy. + * + */ + public static T copyParallel(T original) { + try { + PipedOutputStream outputStream = new PipedOutputStream(); + PipedInputStream inputStream = new PipedInputStream(outputStream); + ObjectOutputStream ois = new ObjectOutputStream(outputStream); + Receiver receiver = new Receiver(inputStream); + try { + ois.writeObject(original); + } finally { + ois.close(); + } + return receiver.getResult(); + } catch (IOException iox) { + // doesn't seem likely to happen as these streams are in memory + throw new RuntimeException(iox); + } + } + + private static class Receiver extends Thread { + + private final InputStream inputStream; + private volatile T result; + private volatile Throwable throwable; + + public Receiver(InputStream inputStream) { + this.inputStream = inputStream; + start(); + } + + @SuppressWarnings("unchecked") + public void run() { + + try { + ObjectInputStream ois = new ObjectInputStream(inputStream); + try { + result = (T) ois.readObject(); + try { + // Some serializers may write more than they actually + // need to deserialize the object, but if we don't + // read it all the PipedOutputStream will choke. + while (inputStream.read() != -1) { + } + } catch (IOException e) { + // The object has been successfully deserialized, so + // ignore problems at this point (for example, the + // serializer may have explicitly closed the inputStream + // itself, causing this read to fail). + } + } finally { + ois.close(); + } + } catch (Throwable t) { + throwable = t; + } + } + + public T getResult() throws IOException { + try { + join(); + } catch (InterruptedException e) { + throw new RuntimeException("Unexpected InterruptedException", e); + } + // join() guarantees that all shared memory is synchronized between + // the two threads + if (throwable != null) { + if (throwable instanceof ClassNotFoundException) { + // actually can not happen in this instance + } + throw new RuntimeException(throwable); + } + return result; + } + } +} \ No newline at end of file diff --git a/src/com/gitblit/wicket/GitBlitWebApp.properties b/src/com/gitblit/wicket/GitBlitWebApp.properties index 7ffdb596..6f321687 100644 --- a/src/com/gitblit/wicket/GitBlitWebApp.properties +++ b/src/com/gitblit/wicket/GitBlitWebApp.properties @@ -187,4 +187,10 @@ gb.recentActivityNone = last {0} days / none gb.dailyActivity = daily activity gb.activeRepositories = active repositories gb.activeAuthors = active authors -gb.commits = commits \ No newline at end of file +gb.commits = commits +gb.teams = teams +gb.teamName = team name +gb.teamMembers = team members +gb.teamMemberships = team memberships +gb.newTeam = new team +gb.permittedTeams = permitted teams \ No newline at end of file diff --git a/src/com/gitblit/wicket/WicketUtils.java b/src/com/gitblit/wicket/WicketUtils.java index e8c5e154..dbeb47f7 100644 --- a/src/com/gitblit/wicket/WicketUtils.java +++ b/src/com/gitblit/wicket/WicketUtils.java @@ -259,6 +259,10 @@ public class WicketUtils { return new PageParameters("user=" + username); } + public static PageParameters newTeamnameParameter(String teamname) { + return new PageParameters("team=" + teamname); + } + public static PageParameters newRepositoryParameter(String repositoryName) { return new PageParameters("r=" + repositoryName); } @@ -377,6 +381,10 @@ public class WicketUtils { return params.getString("user", ""); } + public static String getTeamname(PageParameters params) { + return params.getString("team", ""); + } + public static String getToken(PageParameters params) { return params.getString("t", ""); } diff --git a/src/com/gitblit/wicket/pages/EditRepositoryPage.html b/src/com/gitblit/wicket/pages/EditRepositoryPage.html index 27a5448b..9e221898 100644 --- a/src/com/gitblit/wicket/pages/EditRepositoryPage.html +++ b/src/com/gitblit/wicket/pages/EditRepositoryPage.html @@ -7,7 +7,7 @@ -
+ @@ -24,6 +24,7 @@ + diff --git a/src/com/gitblit/wicket/pages/EditRepositoryPage.java b/src/com/gitblit/wicket/pages/EditRepositoryPage.java index be88bd5f..1a5ec3dd 100644 --- a/src/com/gitblit/wicket/pages/EditRepositoryPage.java +++ b/src/com/gitblit/wicket/pages/EditRepositoryPage.java @@ -75,12 +75,14 @@ public class EditRepositoryPage extends RootSubPage { List federationSets = new ArrayList(); List repositoryUsers = new ArrayList(); + List repositoryTeams = new ArrayList(); if (isCreate) { super.setupPage(getString("gb.newRepository"), ""); } else { super.setupPage(getString("gb.edit"), repositoryModel.name); if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) { repositoryUsers.addAll(GitBlit.self().getRepositoryUsers(repositoryModel)); + repositoryTeams.addAll(GitBlit.self().getRepositoryTeams(repositoryModel)); Collections.sort(repositoryUsers); } federationSets.addAll(repositoryModel.federationSets); @@ -93,6 +95,11 @@ public class EditRepositoryPage extends RootSubPage { repositoryUsers), new CollectionModel(GitBlit.self().getAllUsernames()), new ChoiceRenderer("", ""), 10, false); + // teams palette + final Palette teamsPalette = new Palette("teams", new ListModel( + repositoryTeams), new CollectionModel(GitBlit.self().getAllTeamnames()), + new ChoiceRenderer("", ""), 10, false); + // federation sets palette List sets = GitBlit.getStrings(Keys.federation.sets); final Palette federationSetsPalette = new Palette("federationSets", @@ -165,8 +172,9 @@ public class EditRepositoryPage extends RootSubPage { // save the repository GitBlit.self().updateRepositoryModel(oldName, repositoryModel, isCreate); - // save the repository access list + // repository access if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) { + // save the user access list Iterator users = usersPalette.getSelectedChoices(); List repositoryUsers = new ArrayList(); while (users.hasNext()) { @@ -178,6 +186,14 @@ public class EditRepositoryPage extends RootSubPage { repositoryUsers.add(repositoryModel.owner); } GitBlit.self().setRepositoryUsers(repositoryModel, repositoryUsers); + + // save the team access list + Iterator teams = teamsPalette.getSelectedChoices(); + List repositoryTeams = new ArrayList(); + while (teams.hasNext()) { + repositoryTeams.add(teams.next()); + } + GitBlit.self().setRepositoryTeams(repositoryModel, repositoryTeams); } } catch (GitBlitException e) { error(e.getMessage()); @@ -215,6 +231,7 @@ public class EditRepositoryPage extends RootSubPage { form.add(new CheckBox("skipSizeCalculation")); form.add(new CheckBox("skipSummaryMetrics")); form.add(usersPalette); + form.add(teamsPalette); form.add(federationSetsPalette); form.add(new Button("save")); diff --git a/src/com/gitblit/wicket/pages/EditTeamPage.html b/src/com/gitblit/wicket/pages/EditTeamPage.html new file mode 100644 index 00000000..84a53e3c --- /dev/null +++ b/src/com/gitblit/wicket/pages/EditTeamPage.html @@ -0,0 +1,24 @@ + + + + + + + +
 


+ + + + + + + + +


 
+
+ +
+ \ No newline at end of file diff --git a/src/com/gitblit/wicket/pages/EditTeamPage.java b/src/com/gitblit/wicket/pages/EditTeamPage.java new file mode 100644 index 00000000..47f3568f --- /dev/null +++ b/src/com/gitblit/wicket/pages/EditTeamPage.java @@ -0,0 +1,169 @@ +/* + * Copyright 2011 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.wicket.pages; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import org.apache.wicket.PageParameters; +import org.apache.wicket.extensions.markup.html.form.palette.Palette; +import org.apache.wicket.markup.html.form.Button; +import org.apache.wicket.markup.html.form.ChoiceRenderer; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.model.CompoundPropertyModel; +import org.apache.wicket.model.util.CollectionModel; +import org.apache.wicket.model.util.ListModel; + +import com.gitblit.Constants.AccessRestrictionType; +import com.gitblit.GitBlit; +import com.gitblit.GitBlitException; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TeamModel; +import com.gitblit.utils.StringUtils; +import com.gitblit.wicket.RequiresAdminRole; +import com.gitblit.wicket.WicketUtils; + +@RequiresAdminRole +public class EditTeamPage extends RootSubPage { + + private final boolean isCreate; + + public EditTeamPage() { + // create constructor + super(); + isCreate = true; + setupPage(new TeamModel("")); + } + + public EditTeamPage(PageParameters params) { + // edit constructor + super(params); + isCreate = false; + String name = WicketUtils.getTeamname(params); + TeamModel model = GitBlit.self().getTeamModel(name); + setupPage(model); + } + + protected void setupPage(final TeamModel teamModel) { + if (isCreate) { + super.setupPage(getString("gb.newTeam"), ""); + } else { + super.setupPage(getString("gb.edit"), teamModel.name); + } + + CompoundPropertyModel model = new CompoundPropertyModel(teamModel); + + List repos = new ArrayList(); + for (String repo : GitBlit.self().getRepositoryList()) { + RepositoryModel repositoryModel = GitBlit.self().getRepositoryModel(repo); + if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) { + repos.add(repo); + } + } + StringUtils.sortRepositorynames(repos); + + List teamUsers = new ArrayList(teamModel.users); + Collections.sort(teamUsers); + + final String oldName = teamModel.name; + final Palette repositories = new Palette("repositories", + new ListModel(new ArrayList(teamModel.repositories)), + new CollectionModel(repos), new ChoiceRenderer("", ""), 10, false); + final Palette users = new Palette("users", new ListModel( + new ArrayList(teamUsers)), new CollectionModel(GitBlit.self() + .getAllUsernames()), new ChoiceRenderer("", ""), 10, false); + Form form = new Form("editForm", model) { + + private static final long serialVersionUID = 1L; + + /* + * (non-Javadoc) + * + * @see org.apache.wicket.markup.html.form.Form#onSubmit() + */ + @Override + protected void onSubmit() { + String teamname = teamModel.name; + if (StringUtils.isEmpty(teamname)) { + error("Please enter a teamname!"); + return; + } + if (isCreate) { + TeamModel model = GitBlit.self().getTeamModel(teamname); + if (model != null) { + error(MessageFormat.format("Team name ''{0}'' is unavailable.", teamname)); + return; + } + } + Iterator selectedRepositories = repositories.getSelectedChoices(); + List repos = new ArrayList(); + while (selectedRepositories.hasNext()) { + repos.add(selectedRepositories.next().toLowerCase()); + } + teamModel.repositories.clear(); + teamModel.repositories.addAll(repos); + + Iterator selectedUsers = users.getSelectedChoices(); + List members = new ArrayList(); + while (selectedUsers.hasNext()) { + members.add(selectedUsers.next().toLowerCase()); + } + teamModel.users.clear(); + teamModel.users.addAll(members); + + try { + GitBlit.self().updateTeamModel(oldName, teamModel, isCreate); + } catch (GitBlitException e) { + error(e.getMessage()); + return; + } + setRedirect(false); + if (isCreate) { + // create another team + info(MessageFormat.format("New team ''{0}'' successfully created.", + teamModel.name)); + setResponsePage(EditTeamPage.class); + } else { + // back to users page + setResponsePage(UsersPage.class); + } + } + }; + + // field names reflective match TeamModel fields + form.add(new TextField("name")); + form.add(repositories); + form.add(users); + + form.add(new Button("save")); + Button cancel = new Button("cancel") { + private static final long serialVersionUID = 1L; + + @Override + public void onSubmit() { + setResponsePage(UsersPage.class); + } + }; + cancel.setDefaultFormProcessing(false); + form.add(cancel); + + add(form); + } +} diff --git a/src/com/gitblit/wicket/pages/EditUserPage.html b/src/com/gitblit/wicket/pages/EditUserPage.html index ceda3cbf..978393bb 100644 --- a/src/com/gitblit/wicket/pages/EditUserPage.html +++ b/src/com/gitblit/wicket/pages/EditUserPage.html @@ -7,7 +7,7 @@ -
+ @@ -15,6 +15,9 @@ + + + diff --git a/src/com/gitblit/wicket/pages/EditUserPage.java b/src/com/gitblit/wicket/pages/EditUserPage.java index 8955e222..799cf01d 100644 --- a/src/com/gitblit/wicket/pages/EditUserPage.java +++ b/src/com/gitblit/wicket/pages/EditUserPage.java @@ -17,6 +17,7 @@ package com.gitblit.wicket.pages; import java.text.MessageFormat; import java.util.ArrayList; +import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -38,6 +39,7 @@ import com.gitblit.GitBlit; import com.gitblit.GitBlitException; import com.gitblit.Keys; import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; import com.gitblit.utils.StringUtils; import com.gitblit.wicket.RequiresAdminRole; @@ -82,10 +84,19 @@ public class EditUserPage extends RootSubPage { repos.add(repo); } } + List userTeams = new ArrayList(); + for (TeamModel team : userModel.teams) { + userTeams.add(team.name); + } + Collections.sort(userTeams); + final String oldName = userModel.username; final Palette repositories = new Palette("repositories", new ListModel(new ArrayList(userModel.repositories)), new CollectionModel(repos), new ChoiceRenderer("", ""), 10, false); + final Palette teams = new Palette("teams", new ListModel( + new ArrayList(userTeams)), new CollectionModel(GitBlit.self() + .getAllTeamnames()), new ChoiceRenderer("", ""), 10, false); Form form = new Form("editForm", model) { private static final long serialVersionUID = 1L; @@ -109,7 +120,8 @@ public class EditUserPage extends RootSubPage { return; } } - boolean rename = !StringUtils.isEmpty(oldName) && !oldName.equalsIgnoreCase(username); + boolean rename = !StringUtils.isEmpty(oldName) + && !oldName.equalsIgnoreCase(username); if (!userModel.password.equals(confirmPassword.getObject())) { error("Passwords do not match!"); return; @@ -154,6 +166,17 @@ public class EditUserPage extends RootSubPage { } userModel.repositories.clear(); userModel.repositories.addAll(repos); + + Iterator selectedTeams = teams.getSelectedChoices(); + userModel.teams.clear(); + while (selectedTeams.hasNext()) { + TeamModel team = GitBlit.self().getTeamModel(selectedTeams.next()); + if (team == null) { + continue; + } + userModel.teams.add(team); + } + try { GitBlit.self().updateUserModel(oldName, userModel, isCreate); } catch (GitBlitException e) { @@ -185,6 +208,7 @@ public class EditUserPage extends RootSubPage { form.add(new CheckBox("canAdmin")); form.add(new CheckBox("excludeFromFederation")); form.add(repositories); + form.add(teams); form.add(new Button("save")); Button cancel = new Button("cancel") { diff --git a/src/com/gitblit/wicket/pages/UsersPage.html b/src/com/gitblit/wicket/pages/UsersPage.html index 4d14496d..edb85f7d 100644 --- a/src/com/gitblit/wicket/pages/UsersPage.html +++ b/src/com/gitblit/wicket/pages/UsersPage.html @@ -5,6 +5,8 @@ lang="en"> +
[teams panel]
+
[users panel]
diff --git a/src/com/gitblit/wicket/pages/UsersPage.java b/src/com/gitblit/wicket/pages/UsersPage.java index b54b968d..9526deae 100644 --- a/src/com/gitblit/wicket/pages/UsersPage.java +++ b/src/com/gitblit/wicket/pages/UsersPage.java @@ -16,6 +16,7 @@ package com.gitblit.wicket.pages; import com.gitblit.wicket.RequiresAdminRole; +import com.gitblit.wicket.panels.TeamsPanel; import com.gitblit.wicket.panels.UsersPanel; @RequiresAdminRole @@ -25,6 +26,8 @@ public class UsersPage extends RootPage { super(); setupPage("", ""); + add(new TeamsPanel("teamsPanel", showAdmin).setVisible(showAdmin)); + add(new UsersPanel("usersPanel", showAdmin).setVisible(showAdmin)); } } diff --git a/src/com/gitblit/wicket/panels/TeamsPanel.html b/src/com/gitblit/wicket/panels/TeamsPanel.html new file mode 100644 index 00000000..af1d56de --- /dev/null +++ b/src/com/gitblit/wicket/panels/TeamsPanel.html @@ -0,0 +1,44 @@ + + + + + + +
[admin links]
+ +
 
 


 
+ + + + + + + + + + +
+ + [teams] +
[teamname]
+ + + +
+ + + + +
+
+ + + [edit] | [delete] + + + + + \ No newline at end of file diff --git a/src/com/gitblit/wicket/panels/TeamsPanel.java b/src/com/gitblit/wicket/panels/TeamsPanel.java new file mode 100644 index 00000000..33afb514 --- /dev/null +++ b/src/com/gitblit/wicket/panels/TeamsPanel.java @@ -0,0 +1,89 @@ +/* + * Copyright 2011 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.wicket.panels; + +import java.text.MessageFormat; +import java.util.List; + +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.link.Link; +import org.apache.wicket.markup.html.panel.Fragment; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; + +import com.gitblit.GitBlit; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.pages.EditTeamPage; + +public class TeamsPanel extends BasePanel { + + private static final long serialVersionUID = 1L; + + public TeamsPanel(String wicketId, final boolean showAdmin) { + super(wicketId); + + Fragment adminLinks = new Fragment("adminPanel", "adminLinks", this); + adminLinks.add(new BookmarkablePageLink("newTeam", EditTeamPage.class)); + add(adminLinks.setVisible(showAdmin)); + + final List teamnames = GitBlit.self().getAllTeamnames(); + DataView teamsView = new DataView("teamRow", new ListDataProvider( + teamnames)) { + private static final long serialVersionUID = 1L; + private int counter; + + @Override + protected void onBeforeRender() { + super.onBeforeRender(); + counter = 0; + } + + public void populateItem(final Item item) { + final String entry = item.getModelObject(); + LinkPanel editLink = new LinkPanel("teamname", "list", entry, EditTeamPage.class, + WicketUtils.newTeamnameParameter(entry)); + WicketUtils.setHtmlTooltip(editLink, getString("gb.edit") + " " + entry); + item.add(editLink); + Fragment teamLinks = new Fragment("teamLinks", "teamAdminLinks", this); + teamLinks.add(new BookmarkablePageLink("editTeam", EditTeamPage.class, + WicketUtils.newTeamnameParameter(entry))); + Link deleteLink = new Link("deleteTeam") { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick() { + if (GitBlit.self().deleteTeam(entry)) { + teamnames.remove(entry); + info(MessageFormat.format("Team ''{0}'' deleted.", entry)); + } else { + error(MessageFormat.format("Failed to delete team ''{0}''!", entry)); + } + } + }; + deleteLink.add(new JavascriptEventConfirmation("onclick", MessageFormat.format( + "Delete team \"{0}\"?", entry))); + teamLinks.add(deleteLink); + item.add(teamLinks); + + WicketUtils.setAlternatingBackground(item, counter); + counter++; + } + }; + add(teamsView.setVisible(showAdmin)); + } +} diff --git a/src/com/gitblit/wicket/panels/UsersPanel.html b/src/com/gitblit/wicket/panels/UsersPanel.html index c81a3fd9..1cc19ee5 100644 --- a/src/com/gitblit/wicket/panels/UsersPanel.html +++ b/src/com/gitblit/wicket/panels/UsersPanel.html @@ -13,7 +13,7 @@ - [username] + [users] diff --git a/tests/com/gitblit/tests/UserServiceTest.java b/tests/com/gitblit/tests/UserServiceTest.java index 3f410fa5..93e7f606 100644 --- a/tests/com/gitblit/tests/UserServiceTest.java +++ b/tests/com/gitblit/tests/UserServiceTest.java @@ -38,6 +38,7 @@ public class UserServiceTest { file.delete(); IUserService service = new FileUserService(file); testUsers(service); + testTeams(service); file.delete(); } @@ -47,6 +48,7 @@ public class UserServiceTest { file.delete(); IUserService service = new ConfigUserService(file); testUsers(service); + testTeams(service); file.delete(); } @@ -106,4 +108,109 @@ public class UserServiceTest { testUser = service.getUserModel("test"); assertTrue(testUser.hasRepository("newrepo1")); } + + protected void testTeams(IUserService service) { + + // confirm we have no teams + assertEquals(0, service.getAllTeamNames().size()); + + // remove newrepo1 from test user + // now test user has no repositories + UserModel user = service.getUserModel("test"); + user.repositories.clear(); + service.updateUserModel(user); + user = service.getUserModel("test"); + assertEquals(0, user.repositories.size()); + assertFalse(user.canAccessRepository("newrepo1")); + assertFalse(user.canAccessRepository("NEWREPO1")); + + // create test team and add test user and newrepo1 + TeamModel team = new TeamModel("testteam"); + team.addUser("test"); + team.addRepository("newrepo1"); + service.updateTeamModel(team); + + // confirm 1 user and 1 repo + team = service.getTeamModel("testteam"); + assertEquals(1, team.repositories.size()); + assertEquals(1, team.users.size()); + + // confirm team membership + user = service.getUserModel("test"); + assertEquals(0, user.repositories.size()); + assertEquals(1, user.teams.size()); + + // confirm team access + assertTrue(team.hasRepository("newrepo1")); + assertTrue(user.hasTeamAccess("newrepo1")); + assertTrue(team.hasRepository("NEWREPO1")); + assertTrue(user.hasTeamAccess("NEWREPO1")); + + // rename the team and add new repository + team.addRepository("newrepo2"); + team.name = "testteam2"; + service.updateTeamModel("testteam", team); + + team = service.getTeamModel("testteam2"); + user = service.getUserModel("test"); + + // confirm user and team can access newrepo2 + assertEquals(2, team.repositories.size()); + assertTrue(team.hasRepository("newrepo2")); + assertTrue(user.hasTeamAccess("newrepo2")); + assertTrue(team.hasRepository("NEWREPO2")); + assertTrue(user.hasTeamAccess("NEWREPO2")); + + // delete testteam2 + service.deleteTeam("testteam2"); + team = service.getTeamModel("testteam2"); + user = service.getUserModel("test"); + + // confirm team does not exist and user can not access newrepo1 and 2 + assertEquals(null, team); + assertFalse(user.canAccessRepository("newrepo1")); + assertFalse(user.canAccessRepository("newrepo2")); + + // create new team and add it to user + // this tests the inverse team creation/team addition + team = new TeamModel("testteam"); + team.addRepository("NEWREPO1"); + team.addRepository("NEWREPO2"); + user.teams.add(team); + service.updateUserModel(user); + + // confirm the inverted team addition + user = service.getUserModel("test"); + team = service.getTeamModel("testteam"); + assertTrue(user.hasTeamAccess("newrepo1")); + assertTrue(user.hasTeamAccess("newrepo2")); + assertTrue(team.hasUser("test")); + + // drop testteam from user and add nextteam to user + team = new TeamModel("nextteam"); + team.addRepository("NEWREPO1"); + team.addRepository("NEWREPO2"); + user.teams.clear(); + user.teams.add(team); + service.updateUserModel(user); + + // confirm implicit drop + user = service.getUserModel("test"); + team = service.getTeamModel("testteam"); + assertTrue(user.hasTeamAccess("newrepo1")); + assertTrue(user.hasTeamAccess("newrepo2")); + assertFalse(team.hasUser("test")); + team = service.getTeamModel("nextteam"); + assertTrue(team.hasUser("test")); + + // delete the user and confirm team no longer has user + service.deleteUser("test"); + team = service.getTeamModel("testteam"); + assertFalse(team.hasUser("test")); + + // delete both teams + service.deleteTeam("testteam"); + service.deleteTeam("nextteam"); + assertEquals(0, service.getAllTeamNames().size()); + } } \ No newline at end of file -- 2.39.5